Block Editor: enforce the Story Block capability + delivery contract — 6 live defects, incl. silent node deletion and 461 blank published articles
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 970
- Forks
- 486
- Avg merge
- 3d 33m
- Merged PRs (30d)
- 170
Description
Problem Statement
Ten customer tickets, eight GitHub issues and seven merged PRs across sixteen months all
resolve to one unenforced contract. Support prescribed the same workaround in four of those
tickets — "uncheck everything under Allowed Blocks and save" — and in a fifth closed the ticket
as user error, without anyone noticing it was the same defect wearing five different symptoms.
[!IMPORTANT]
Scope: frontend only. Everything below lives incore-web/**, plus two Velocity
templates underdotCMS/src/main/webapp/WEB-INF/velocity/:
VM_global_library.vm(#macro(renderMarks), defect D) and
static/storyblock/render.vtl(#macro(renderContentBlock), defect D2 — see D3 for why the
caller has to change too). No Java, no REST contract, no database, no ElasticSearch, no
indexing. See Out of scope for what was deliberately left out.The VTL path is in scope on purpose. It is the delivery path for traditional (non-headless)
dotCMS pages. Leaving it out would make invariant I3 pass with one renderer still broken — which
is precisely how this class of defect escaped six times.
The contract, and why it keeps breaking
PR #37539 wrote the rule down in
specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md:
A capability may be gated on Allowed Blocks only if the Settings tab can produce its
identifier. A capability that is consulted but not producible is permanently forbidden the
instant an administrator restricts the field to anything at all — an outcome nobody chose and
nobody can undo. Gating such a capability is always a defect, never a configuration.
It is a markdown file. Nothing enforces it. There is no single source of truth for capability
identifiers — there are six parallel registries that must agree and are never checked against
each other:
| # | Registry | Role | Location |
|---|---|---|---|
| 1 | getEditorBlockOptions() |
what Settings can write | core-web/libs/block-editor/src/lib/shared/utils/suggestion.utils.ts:115 |
| 2 | has() / store.isAllowed() |
what the new editor reads | core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts:71 + toolbar / slash menu / popovers |
| 3 | relatedContent + getBlockMap |
what the legacy editor reads and deletes with | core-web/libs/block-editor/src/lib/shared/utils/parser.utils.ts:57,131 |
| 4 | Registered marks | getEditorMarks() vs createEditorExtensions() |
both editors |
| 5 | Delivery renderers ×4 | VTL · React SDK · Vue SDK · Angular SDK (classic) | see Defect D |
| 6 | Angular SDK semantic renderer | a second, independent Angular renderer — the only one that degrades correctly | .../dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html:355 |
Registry 6 is the important one: the correct behaviour already exists in this repo and was
never propagated to the other four.
The two failure modes
When a key is consulted but not producible, the capability is dropped on every restricted field.
What happens next depends on what was dropped:
| Dropped | New Block Editor | Legacy Block Editor |
|---|---|---|
A mark (link, highlight) |
TipTap parses the document atomically → Node.fromJSON aborts → field renders blank over intact stored JSON → a Save on top makes the loss real |
registers Link/Highlight unconditionally → not affected |
A node (emoji, youtube, customGallery, dotAudio) |
degrades to an Unsupported block placeholder — visible but wrong |
purifyNodeTree deletes the node from the document at load time → lost on next save |
The shape is the same in both: fail closed. One element the code does not fully recognise
discards the whole document instead of degrading the part it cannot render. Defect E shows the
delivery layer doing exactly the same thing to content the editor never authored.
Sixteen months, six rounds, no connection drawn
Every round was triaged as an isolated incident. Most carry Team : Maintenance +
Sprint: Injected — the signature of an unplanned customer escalation.
| # | Date | Issue | Trigger | Outcome |
|---|---|---|---|---|
| 1 | 2022-08 → 2023-05 | #22164 · #23764 · #24370 | Allowed Blocks introduced, then crashes on restriction | closed one by one |
| 2 | 2024-01 | #27101 | crash on table selection in allowed blocks | closed |
| 3 | 2025-05 | #29772 | paragraph missing from the options list |
closed |
| 4 | 2025-12 | #34110 | bubble menu ignores allowedBlocks |
Customer Deployed |
| 5 | 2026-03 | #35032 ← FD #35973 | restricting image/video blanks the editor; blocked a customer redesign launch | hotfix deployed to the customer |
| 6 | 2026-06 → 2026-09 | #36351 · #37145 · #37175 · #37340 | link, highlight, emoji — four ad-hoc ungatings in ten weeks | this issue |
The family this issue closes
| Freshdesk | Customer | Issue | PR | Symptom | Key at fault |
|---|---|---|---|---|---|
| #37852 (+38035, +38092) | Muskegon County | #36351 | #37539 | hyperlink button vanished | link (authoring gate) |
| #38349 | Methodist Health | none | — | hyperlink button vanished — closed as user error, no issue filed | link (authoring gate) |
| #38997 | Michigan State University | #37175 | #37205 → #37313 | Block Editor shows no content | link (mark registration) |
| #38993 | Worldline / Logeecom | #37175 | ditto | empty contentlet despite content | link (mark registration) |
| #38957 | CarFinance247 | #37145 | #37149 | Guide body renders blank | highlight (unregistered) |
| #35973 | University of Akron | #35032 | — | editor does not render when image/video restricted | image/video gate |
| #39197 | Lennox Commercial | #37340 | #37442 | © ® ™ split one link into two anchors |
emoji (gate + node) |
| #38460 | — | #36668 | — | custom remote blocks stripped on load | customGallery (not producible) |
| #38931 | CITGO | none | — | 461 migrated articles render an empty body | see Defect E |
| — | — | #36572 | #36574 | Underline mark + button missing | underline (unregistered) |
FD #38349 is the one to read. On 2026-07-15 a
customer reported the hyperlink button missing. Support diagnosed "a stray empty value in Allowed
Blocks", told them to clear the field, and closed it in two hours with
Github Issue Filed: No. #36351 — the real defect — had been open for sixteen days at that point
and was not fixed until 2026-09-16. The triaging agent's own internal note called it:
"The underlying value of the blank chip is unverified … If the stray value appeared on its
own (e.g. after the recent Block Editor v3 update), that's worth a GitHub issue — ask for
confirmation before filing."
Nobody asked. The same note cites #34110 and #29772 — two prior rounds of this exact class — and
still reaches "configuration". The class does not only get patched symptom by symptom; it also
gets closed as user error. That is what an invariant test prevents and a markdown contract does
not.
A related root cause, deliberately not in this issue's scope
FD #38740 (Worldline) — embedded contentlets
unselectable — is the same disease on a different axis: not capability keys, but attribute
serialization. The legacy and new editors write byte-different JSON for the same document, and
#36985 broke because the load path compared byte-wise. PR #37319 measured four divergences
(chartCount typo, absent indent, listItem.textAlign, alphabetical attr key order) and notes
that listItem.textAlign is a live divergence the legacy editor still writes today. It was
fixed structurally there (utils/content-match.utils.ts); the issue was reopened twice before
it held. Recorded here so the next person sees the pattern, not re-litigated here.
Five defects still live on main
A, B, C and D were verified against the working tree at 667fc831ee. D2 and E were verified in
the same tree and are additionally evidenced by customer tickets. None has an open issue.
🔴 A — Legacy editor deletes dotAudio and aiContent nodes (silent data loss)
relatedContent (parser.utils.ts:57) is a hand-maintained alias table mapping Settings
vocabulary → TipTap node names. It has entries for image → dotImage and
video → dotVideo. It has no entry for audio → dotAudio, and none for
aiContentPrompt → aiContent.
Executing the real logic of parser.utils.ts (imports stripped, constants inlined, functions
otherwise verbatim):
blockMap for [paragraph, audio] : {paragraph,text,doc,hardBreak,dotUnsupportedBlock,audio}
AUDIO allowed=[paragraph,audio] -> ['paragraph'] ← dotAudio DELETED
AI allowed=[paragraph,aiContentPrompt] -> ['paragraph'] ← aiContent DELETED
IMAGE allowed=[paragraph,image] (control) -> ['paragraph','dotImage'] ← survives
VIDEO allowed=[paragraph,video] (control) -> ['paragraph','dotVideo'] ← survives
The administrator ticks "Audio" in Allowed Blocks and the editor removes every stored audio
block on open. This is strictly worse than the ticketed defects: there the stored JSON stayed
intact until someone saved; here the node is gone from the in-memory tree at load and the next
Save makes it permanent.
The load path is dot-block-editor.component.ts:774-805 — preserveUnknownBlockNodes →
preserveUnknownBlockMarks → removeInvalidNodes. A registered node such as dotAudio
survives the preservation pass precisely because it is known, and is then deleted by the filter.
The same _customNodes map (dot-block-editor.component.ts:148-156) is keyed by Settings
vocabulary yet contains aiContent and loader — keys Settings can never emit — so those
extensions are never registered on a restricted field either.
🟠 B — youtube is still gated on a non-producible key
| Site | Line |
|---|---|
core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.ts |
225 — showAssetByUrl() |
core-web/libs/new-block-editor/src/lib/editor/components/asset-by-url-popover/asset-by-url-popover.component.ts |
83 |
youtube is not in getEditorBlockOptions(), so isAllowed('youtube') is true only on a
field with no restriction whatsoever. Restricting a field to anything at all silently removes the
YouTube tab from "Add asset by URL". link (#36351) and emoji (#37340) were ungated; this is
the third of the trio from #37175 and was left behind — it is recorded as a known violation,
out of scope in the capability-keys contract.
🟠 C — aiContent / aiImage: the gate is inverted
core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts:468,476
consults blockName: 'aiContent' and blockName: 'aiImage'. Settings produces
aiContentPrompt and aiImagePrompt (suggestion.utils.ts:91,96).
The consequence is perverse: ticking "AI Content" in Settings is exactly what hides it from
the slash menu, because the field is then restricted and isAllowed('aiContent') is false. Not
ticking it hides it too. No configuration shows AI Content on a restricted field. Also recorded
as a known violation in the contract.
🔴 D — The delivery layer has the same bug and has never been audited
The new editor's schema declares 10 marks (confirmed in the running app by QA on #37175):
bold, code, dotUnsupportedMark, highlight, italic, link, strike, subscript, superscript, underline. The legacy editor registers the same set. There are five renderers and every one
of them knows 7:
| Renderer | File | Coverage | On an unknown mark |
|---|---|---|---|
| Angular SDK (classic) | core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/text.component.ts:125 |
7/10 | @default { {{ text }} } — stops recursing |
| React SDK | core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Texts.tsx:128 |
7/10 | if (!Component) { return text; } — stops recursing |
| Vue SDK | core-web/libs/sdk/vue/src/lib/components/DotCMSBlockEditorRenderer/components/blocks/TextBlock.vue:19-26,39-45 |
7/10 | v-if="tag" → falls to {{ text }} — stops recursing |
| VTL macro | dotCMS/src/main/webapp/WEB-INF/velocity/VM_global_library.vm:7 (#macro(renderMarks)) — one implementation, two entry points: the global macro and static/storyblock/render.vtl:97, which delegates to it |
7/10 | ignores the mark (degrades correctly — flat loop, not recursive) |
| Angular SDK (semantic) | core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer-semantic/dotcms-block-editor-renderer-native.component.html:355-362 |
7/10 | skips the mark and keeps recursing — correct |
Missing in all five: code, highlight, dotUnsupportedMark. Three concrete consequences:
- The new editor ships an always-available inline-code button
(toolbar.component.html:107→toggleCode(), ungated). An author applies it, sees it in the
editor, and the front end does not paint it. Worse: on text carryingmarks: [code, bold]
the React, Vue and classic-Angular renderers also drop the bold, because the unknown mark
ends the recursion. dotUnsupportedMark— the placeholder introduced by #37313 — can legitimately reach
storage (documented in that PR's own footnote: when the preserved payload no longer parses,
restoreUnknownMarkkeeps the placeholder so the raw payload still round-trips). When it does,
those three renderers stop walking that node's marks. The fix that prevented editor-side data
loss opened a formatting-loss path in delivery.- The correct behaviour is already written. The semantic Angular renderer does exactly what
the other three must do, with the reasoning in a comment
("Unknown mark type: skip it but keep rendering any marks beneath."). This is not a design
question; it is an unpropagated fix.
D2 — adjacent text nodes sharing one link mark still render as two anchors
#37340 declared two defects and said "Both must be fixed — they are separate defects that
happen to surface together here." Only one was. PR #37442 states its own scope plainly:
"Jest only: no Java, DB, REST, renderer or build artifact is touched."
It solved the emoji split by healing the data in the editor — merging the text nodes on load.
That closes the path for content authored from now on. It does nothing for the Gap B the issue
identified:
- VTL emits one
<a>per text node (VM_global_library.vm:35); the three SDKTextBlock
components do the same. - So any stored JSON containing adjacent text nodes with an identical
linkmark still renders
two anchors — two tab stops, two entries in the NVDA elements list, WCAG 2.2 A failures
(1.3.1, 2.4.4, 4.1.2), exactly as FD #39197
reported. - That shape reaches storage from migrations, imports, the legacy editor, the REST API and any
third-party writer — none of which pass through the new editor's heal.
D3 — Why VTL is the highest-risk of the five renderers, and how to not break it
The other four renderers are TypeScript with a Jest suite behind them. The Velocity path has
neither, so every rule below has to be enforced by an explicit check or it will rot again.
The macro is two mirrored loops, not one. #renderMarks emits every opening tag in forward
order ($range = [$start..$end], VM_global_library.vm:13-44) and every closing tag in reverse
($range = [$end..$start], lines 46-70). A mark added to the opening loop and forgotten in the
closing loop does not "render without the tag" — it produces malformed, unbalanced HTML on
every published page carrying that mark. This is the single most likely way to break production
while fixing D.
$start and $end are set in the first block and reused in the second. They are Velocity
globals with no re-declaration in the closing block. The current code works; anything that moves
or conditionalises the first #if($content.marks) silently breaks the second.
D2 cannot be fixed inside #renderMarks. The macro receives one text node at a time —
it is called from #renderContentBlock at static/storyblock/render.vtl:97, inside the
per-element loop, and that is its only call site in the codebase. Coalescing adjacent nodes that
share a link mark therefore requires a look-ahead in the caller, not in renderMarks. This
is why D2's footprint is two files and D's is one.
There is no .vm test harness in core-web. Do not add a Velocity runtime or a Java
integration test for this — both are out of scope. Enforce the parity textually instead: a
Jest spec that reads the .vm file as a string and asserts that every mark name in the allowlist
appears in both loops with its matching open and close tag. It is a crude check and it is
exactly the right one here: it fails loudly on the one mistake that matters, it needs no runtime,
and it lives in the same suite as I3.
🔴 E — The delivery validator fails closed: one empty node blanks the entire body
isValidBlocks (core-web/libs/sdk/uve/src/lib/editor/internal.ts:65-70) rejects any node whose
content is an empty array, recursively, at any depth:
if (blocks.content.length === 0) {
return { error: 'Error: Blocks content is empty. At least one block is required.' };
}
All four consuming renderers — React, Vue, Angular classic, Angular semantic — treat that as
fatal and render nothing for the whole field. This is the delivery-side twin of TipTap's
atomic Node.fromJSON: a single unrecognised element discards the entire document.
{"type":"paragraph","content":[]} is a shape the dotCMS editor never emits — it omits the
content key on an empty paragraph. But the editor is not the only writer. In
FD #38931 a Kentico→dotCMS migration translated
<p></p> spacer paragraphs literally, and 461 published articles rendered a completely blank
body while the page shell, title and hero image loaded normally. Republishing did not help —
the data is the payload. Support's verification is on the ticket: the JSON confirmed via
/api/content/query, the affected count scoped by owner and migration run date (Jul 23: 82,
Aug 12: 103, Aug 14: 276), zero natively-authored articles affected.
The customer's fix was a stripEmptyBlocks sanitiser in their own React code, shipped by
dotCMS engineering as a workaround. The ticket's internal note recorded the obvious follow-up —
"file an SDK graceful-degradation issue — a single empty paragraph node should be skipped, not
blank the entire body" — and no issue was ever filed. Confirmed: nothing in dotCMS/core
matches the error string or the validator.
Steps to Reproduce
A — Legacy editor deletes allowed audio blocks
- Set
FEATURE_FLAG_NEW_BLOCK_EDITOR=false. - On a content type with a Block Editor field, open field settings → Allowed Blocks → select
Audio and Paragraph-adjacent blocks (any list with ≥ 1 entry besides paragraph). - Temporarily clear Allowed Blocks, create a contentlet, insert an audio block, Save.
Confirm viaGET /api/v1/content/{identifier}that the stored JSON contains
{"type":"dotAudio", ...}. - Restore Allowed Blocks to include Audio.
- Reopen the contentlet.
Actual: the audio block is gone from the editor. Typing anything and saving removes it from
storage permanently.
Expected: the audio block loads and renders.
Variant: repeat with an aiContent node and AI Content ticked in Allowed Blocks — same
result.
B — Restricting any block kills the YouTube tab
FEATURE_FLAG_NEW_BLOCK_EDITOR=true(default).- Field settings → Allowed Blocks → select e.g.
Bulleted Listonly. - In a contentlet, open the toolbar's Add asset by URL popover.
Actual: the YouTube option is disabled/absent. Expected: present — youtube is not
something an administrator can restrict, so it must never be gated.
C — Ticking "AI Content" hides AI Content
FEATURE_FLAG_NEW_BLOCK_EDITOR=true, AI plugin installed.- Field settings → Allowed Blocks → select AI Content and AI Image (plus anything else).
- In a contentlet, type
/to open the slash menu.
Actual: AI Content and AI Image are absent. Expected: present, because they were
explicitly allowed.
D — Inline code and unknown marks are destroyed on the front end
- In the new Block Editor, select text, apply inline code (toolbar), then bold the same
run so it storesmarks: [{type:'code'},{type:'bold'}]. Save and publish. - Render the field through the Angular SDK (classic), the React SDK, the Vue SDK, and a VTL page
using#renderMarks.
Actual: no <code> anywhere; in React, Vue and classic Angular the text is also not bold.
Expected: the code mark renders as <code>, and in every case the marks after it still
render.
Variant: inject a dotUnsupportedMark (or any undeclared mark) stacked before bold and render
through the three SDKs — the bold disappears. Render the same payload through the semantic
Angular renderer — the bold survives. That contrast is the specification for the fix.
D2 — One link, two anchors, without any emoji involved
- Write this directly to a Story Block field via
/api/v1/content(no editor involved — this is
the point):[ {"type":"text","marks":[{"type":"link","attrs":{"href":"https://dotcms.com"}}],"text":"one "}, {"type":"text","marks":[{"type":"link","attrs":{"href":"https://dotcms.com"}}],"text":"link"} ] - Render through VTL, React, Vue and both Angular renderers.
Actual: <a href="…">one </a><a href="…">link</a> — two anchors, two tab stops.
Expected: a single <a>. Adjacent text nodes carrying an identical link mark are one link.
E — One empty paragraph blanks a whole article
- Store a Story Block value containing an empty-array node anywhere in the tree:
{"type":"doc","content":[ {"type":"paragraph","content":[{"type":"text","text":"This paragraph must render."}]}, {"type":"paragraph","content":[]} ]} - Render the field through the React, Vue or either Angular renderer.
Actual: the field renders nothing at all; the console shows
Error in nested block at index 1: Error: Blocks content is empty. At least one block is required.
Expected: the first paragraph renders and the empty node is skipped.
Acceptance Criteria
A — The legacy load path stops destroying content
-
removeInvalidNodesis no longer called on the load path in
dot-block-editor.component.ts:797-804.setEditorJSONContentreturnspreservedContent
unconditionally. - A node type that is registered in the schema loads as itself, whatever
allowedBlocks
says — implementing the principle already stated in #37175: "Allowed Blocks restricts which
blocks an author can insert; it must never prevent existing content from loading." - A node type that is not registered still arrives as a
dotUnsupportedBlockplaceholder
via the existingpreserveUnknownBlockNodespass, and still round-trips its original JSON
byte-for-byte on save. - A field whose Allowed Blocks includes Audio loads stored
dotAudionodes, and a Save
round-trip preserves them. - A field whose Allowed Blocks includes AI Content loads stored
aiContentnodes (as the
real node when registered, as a placeholder otherwise) — never deleted. - Accepted behavioural consequence, called out for the reviewer: legacy content containing
a node the field now excludes (e.g. atableon a field restricted to lists) renders again
instead of being deleted. This matches the new editor exactly and is the intended outcome —
restriction governs insertion, not loading. - Insertion restriction is unchanged: the
+action menu, slash menu and bubble menu on a
restricted field still offer only the allowed blocks. -
parser.utils.spec.tsis updated — its current suite pins the deleting behaviour and will
fail. Assertions that encode "nodes outsideallowedBlocksare dropped from loaded content"
are replaced by assertions that they are preserved. -
removeInvalidNodes/purifyNodeTree/getBlockMapare either deleted outright (if
nothing outsidedot-block-editor.component.tsconsumes them — they are re-exported through
libs/block-editor/src/lib/shared/index.ts) or kept with a doc comment stating they are no
longer part of the load path. State which, and why, in the PR description.
B — youtube is ungated
-
toolbar.component.ts:225no longer consultsisAllowed('youtube'). -
asset-by-url-popover.component.ts:83no longer consultsisAllowed('youtube'). - A field restricted to any subset still offers the YouTube tab in "Add asset by URL".
-
imageandvideoin the same popover remain gated — they are producible.
C — AI keys are corrected
-
slash-menu-catalog.ts:468,476consultsaiContentPrompt/aiImagePrompt. - Ticking AI Content / AI Image in Allowed Blocks makes them appear in the slash menu
of a restricted field. - Not ticking them, on an otherwise restricted field, keeps them hidden.
- An unrestricted field shows both, as today.
- The AI plugin's own availability check (
store.aiInstalled()) is unchanged and still wins.
D — Delivery-layer mark parity
- Angular SDK (classic)
text.component.tsrenders thecodemark as<code>. - React SDK
Texts.tsxrenders thecodemark as<code>. - Vue SDK
TextBlock.vuerenders thecodemark as<code>. - Angular SDK (semantic)
dotcms-block-editor-renderer-native.component.htmlrenders the
codemark as<code>. - VTL
#macro(renderMarks)emits<code>in the opening loop
(VM_global_library.vm:13-44) and</code>in the closing loop (lines46-70),
each in the position that keeps the two loops exact mirrors of one another. Verify the
rendered HTML is balanced formarks: [code, bold]andmarks: [bold, code]— an
open-without-close here corrupts every published page carrying the mark, it does not merely
drop a tag. - No change to
$start/$endhandling: they are set in the first#if($content.marks)
block and consumed by the second (see D3). -
highlightis NOT painted on the front end — decision carried over from #37149: these
marks are UI residue from the legacy link popover'ssetHighlight()selection indicator, not
authorial intent, and there is no authoring button for them. It must, however, stop
swallowing subsequent marks (next bullet). -
dotUnsupportedMarkis NOT painted either, for the same reason. - React, Vue and classic-Angular keep recursing through the remaining marks when a mark has
no renderer — this is the core fix, and the semantic Angular renderer
(...native.component.html:355-362) is the reference implementation to copy, comment
included.marks: [code, bold],marks: [highlight, bold]and
marks: [dotUnsupportedMark, bold]all render their text bold. Today all three render
unformatted plain text. - The React renderer's mutation of
mark.attrs(Texts.tsx:124-127writesclassNameonto
anddeletesclassfrom the caller's object) is not made worse by this change; fixing it
is welcome but optional.
D2 — Adjacent identical link marks coalesce into one anchor
- VTL, React, Vue and both Angular renderers emit one
<a>for a run of adjacent text
nodes whoselinkmarks are attribute-identical (href,target,title,rel,
aria-label,class). - The VTL fix goes in
#macro(renderContentBlock)(static/storyblock/render.vtl, the
$element.type == "text"branch at line97), not in#renderMarks— the macro only
ever sees one text node, so the look-ahead belongs to the caller.#renderMarkskeeps its
current single-node contract. - Text nodes whose
linkmarks differ in any attribute still produce separate anchors. - Marks stacked inside the run still render per node —
text(link+bold) + text(link)is<a><strong>…</strong>…</a>, one anchor. - A regression test uses the
#37340payload shape directly (stored JSON, no editor), so the
coverage does not depend on the editor-side heal shipped in #37442. - Closes the "Gap B" left open by #37340 — reference that issue in the test comment.
E — The delivery validator degrades instead of rejecting
-
isValidBlocksno longer treatscontent: []as an error at any depth
(libs/sdk/uve/src/lib/editor/internal.ts:65-70). - A node with an empty
contentarray is skipped; every sibling and ancestor still
renders. The document as a whole is never discarded because of it. - The genuinely fatal cases stay fatal and keep their current messages:
blocksundefined,
not an object,type !== 'doc',contentmissing or not an array, a block missingtype. - A regression test uses the exact FD #38931
payload —{"type":"paragraph","content":[]}nested among real paragraphs — and asserts the
real content renders. - Verified across all four consumers: React, Vue, Angular classic, Angular semantic.
- The customer-side
stripEmptyBlocksworkaround shipped to CITGO becomes unnecessary. Note
in the PR that support can retire it.
Invariants — the reason this issue exists
These are the deliverable. Without them the class returns; it has returned six times in sixteen
months, and once (FD #38349) it was closed as
user error while the real issue sat open.
- I1 — no gate on a non-producible key. A test that imports
getEditorBlockOptions()and
fails if any capability key consulted byeditor-extensions.ts, the toolbar, the slash-menu
catalog, or the asset-by-url popover is absent from that list.paragraphis exempt
(special-cased as always permitted). This test must fail onmaintoday foryoutube,
aiContentandaiImage, and pass after B and C. - I2 — no producible key without a destination. The inverse: every option
getEditorBlockOptions()can emit must resolve to something real in both editors —
a registered extension, a gate, or a documented always-on capability. Fails today for
aiContentPrompt/aiImagePrompt. - I3 — schema-to-delivery mark parity. A test that derives the mark names from the new
editor's schema and fails if any is unhandled by all five renderers: the VTL macro, the
React SDK, the Vue SDK, the classic Angular renderer and the semantic Angular renderer.
A mark deliberately not painted (highlight,dotUnsupportedMark) satisfies it by being on
an explicit, commented allowlist — never by being forgotten. This test must fail onmain
today forcode, in five places. - I3-VTL — the Velocity half of I3 is a text-level assertion.
#renderMarkshas no test
harness and must not get a Velocity runtime or a Java integration test (out of scope). The
spec readsVM_global_library.vmas a string and fails if any allowlisted mark is absent
from either the opening or the closing loop, or if the two loops do not list the same
marks. Cheap, runtime-free, and it catches the unbalanced-HTML mistake that D3 describes. - I4 — no renderer discards a document it can partly render. A test that feeds each of the
five renderers a document containing (a) an unknown mark stacked before a known one,
(b) an unknown node type, and (c) a node withcontent: [], and asserts that every
recognisable sibling still reaches the output. This is the invariant behind defects D and
E, and it is what makes the class stop mattering even when a new mark or node appears that
nobody remembered to register. Fails today in four of the five renderers. - Each invariant test carries a comment naming the issues and tickets it would have caught
(I1: #37175, #36351, #37340, FD #38349 ·
I2: this issue, defect A · I3: #36572, #37145 ·
I4: FD #38931, #37313) so the next person to
hit a red build understands why the rule exists. -
specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.mdis
updated: theyoutubeandaiContent/aiImagerows move from "known violation, out of
scope" to resolved, and the document points at the tests that now enforce it. -
core-web/libs/new-block-editor/CLAUDE.mdrecords the enforced contract and the
never-delete-on-load invariant for the legacy editor. - A short note in the SDK docs states the delivery contract in one line: a renderer never
discards a document it can partly render.
Regression coverage
- The existing suites stay green:
nx test new-block-editor,nx test block-editor,
nx test sdk-angular,nx test sdk-react,nx test sdk-vue,nx test sdk-uve.
Note:nx test block-editorhas 10 pre-existing failures
(this.editor.setEditable is not a function, an incomplete spec mock) — identical on clean
main. Verify by stashing; do not attempt to fix them here. - No change to stored JSON, REST contracts, the GraphQL Story Block fetcher, or the database.
Frontend + one VTL macro only — rollback-safe and a clean backport candidate. - Defect E changes a published SDK contract: documents that previously errored now render.
No consumer can be relying on the blank-body behaviour, but call it out in the changelog for
@dotcms/uve,@dotcms/react,@dotcms/angularand@dotcms/vue.
Implementation guide
Suggested order — each step is independently testable and independently revertable.
| Step | Defect | Files |
|---|---|---|
| 1 | I1 + I3 (red first) | new spec files under libs/new-block-editor/src/lib/editor/extensions/ and a shared parity spec |
| 2 | B | toolbar.component.ts:225, asset-by-url-popover.component.ts:83 |
| 3 | C | slash-menu-catalog.ts:468,476 |
| 4 | I2 (red first) | spec against getEditorBlockOptions() + both editors |
| 5 | A | dot-block-editor.component.ts:797-804, parser.utils.ts, parser.utils.spec.ts |
| 6 | I4 (red first) | one shared spec exercising all five renderers |
| 7 | E | libs/sdk/uve/src/lib/editor/internal.ts + the four consumers |
| 8 | D | text.component.ts, Texts.tsx, TextBlock.vue, VM_global_library.vm — copy the semantic renderer's @default branch |
| 9 | D2 | the same five renderers: coalesce before emitting |
| 10 | docs | capability-keys contract, new-block-editor/CLAUDE.md, SDK changelog |
Write the failing test first for every step — I1, I3 and I4 must be demonstrably red on main
before B, C, D and E are touched. The QA rounds on #37175 are the model: PR #37205 shipped with
AC 5 unmet and QA caught it in the running app, which is what forced #37313. #36985 is the other
model, in the opposite direction: it was reopened twice because the first two fixes addressed
the symptom the ticket described rather than the mechanism underneath.
Verify in the browser, not only in Jest. Every defect in this family was invisible to unit
tests and obvious in the app within thirty seconds. For A specifically, confirm the byte-for-byte
save round-trip on real content — that is the evidence that matters.
Out of scope
These are real and evidenced, but they belong to other subsystems or other owners. Listed so they
are not lost a second time.
-
Indexing is explicitly out of scope for this issue. FD #38957 carries two unrelated problems: the
Block Editor one (thehighlightmark, #37145, in the family above) and an ElasticSearch
reindex incident. The reindex side is backend, belongs to the indexing owners, and is not
tracked anywhere — support reproduced it and drafted the RCA on the ticket. Flagged here only
so it is not lost; nothing about it belongs in this work. -
Block Editor picker pagination — a different defect class, and already resolved.
FD #38277 (RBC GAM, content-type picker capped at
40 → #36487 → PR #36488) and FD #38290 (folder
tree stops at "H",Resourcesunreachable → #36733 → PR #36848) are load-more pagination in two
unrelated components, with no schema involvement. Both are fixed; neither belongs here.Recorded because the second one is a useful counter-example to this issue's thesis. #36733 is
labelledType : Refactoring/dotCMS : Technical Debt, and its acceptance criteria say
load-more must keep working "as before" — it reads as a pure refactor that cannot fix a bug.
It fixed one anyway. The Block Editor's image/video/audio pickers open
DotBrowserSelectorComponent(editor-modal.service.ts:204) →DotSideBarComponent→
browser.store.ts, and before PR #36848 that store called
getFoldersTreeNode(hostname/path)with no pagination arguments at all — one batch, no way
to ask for more. The PR replaced it with{ page, per_page: DOT_FOLDER_TREE_PAGE_SIZE }plus a
loadMorerxMethod and a "Load more" node (dot-sidebar.component.html:13-18).
git log -S "loadMore"on that store returns exactly one commit:8c725747c0b, the refactor
itself. Unifying the threep-treeimplementations gave the Browser Selector a capability it had
never had.The lesson is the same one this issue is about, from the other direction: behaviour moved
between components with nothing asserting what it was. Nobody could tell from the labels, the
ACs or the issue title whether the customer's bug was fixed — it took reading the diff. FD #38290
was linked to the wrong issue twice (#36487, then #36733) before anyone checked. Remaining
caveat, not tracked anywhere: the page size is 40
(DOT_FOLDER_TREE_PAGE_SIZE,SITE_PAGE_LIMIT), so a site with many folders now reaches
Resourcesonly after repeated "Load more" clicks. -
TypeError: o.can(...).mergeCells is not a functionwhentableis not inallowedBlocks
(table-handle-popover.component.ts:283-295instantiates unconditionally and re-binds on every
transaction). Pre-existing, console noise only, flagged by QA on #37175 twice. Deserves its own
issue. -
Security hardening of the
customBlocksremote-extension loader (arbitrary JS in the dotAdmin
origin) — documented in #36668, a different domain (CSP + backend config + permissions). -
Adding a Highlight toolbar button. #37149 argued the case against it and nothing here changes
that reasoning. -
Native text-colour support (FD #38800, #37235) —
a feature request, not a defect, and not this class. -
Any change to the bubble-menu positioning that opened FD #37852. The new editor's static toolbar
already addresses it. Note that #36351 was opened for that symptom and closed for the
hyperlink gate — the positioning complaint was never answered on its own terms.
dotCMS Version
main @ 667fc831ee (2026-09-17). Defects B, C, D and D2 affect every build where
FEATURE_FLAG_NEW_BLOCK_EDITOR is available (since #35257, 2026-05-08). D, D2 and E also affect
every consumer of the Angular/React/Vue SDK renderers and the VTL macro regardless of the flag,
and regardless of which editor wrote the content — E in particular is reachable by any writer
that is not the editor at all. Defect A affects the legacy editor and predates TipTap 3 — the
filter has behaved this way since the TipTap 2 era (~#24007).
Severity
High - Major functionality broken
Defect A causes silent, permanent content loss on a configuration an administrator chose
deliberately. Defect E has already blanked 461 published articles for one customer
(FD #38931) and was resolved by asking them to
patch their own front end. Defects D and D2 silently destroy authored formatting and accessible
link semantics on published pages. Not Critical: each is scoped to fields with Allowed Blocks
configured, to specific marks, or to non-editor-authored content, and A has a no-deploy workaround
(clear Allowed Blocks) — the same workaround support has now given four customers, and diagnosed
as user error for a fifth.
Links
Freshdesk — the family
- #37852 — Muskegon County (merged: 38035, 38092)
- #38349 — Methodist Health · closed as user error, no issue filed
- #38997 — Michigan State University
- #38993 — Worldline / Logeecom
- #38957 — CarFinance247 (
highlight; also carries the untracked reindex incident) - #35973 — University of Akron (2026-03, the #35032 round)
- #39197 — Lennox Commercial (
©®™link split) - #38460 — custom remote blocks
- #38931 — CITGO, 461 blank articles (Defect E)
Freshdesk — related, other classes
- #38740 — Worldline, contentlet selection (#36985, reopened twice; attribute-serialization divergence)
- #38277 — RBC GAM, picker capped at 40 (#36487)
- #38290 — folder tree stops at "H" (mis-linked twice)
- #38800 — text colour, feature request (#37235)
Issues — #36351 · #37145 (still open although PR #37149 merged 2026-08-21) · #37175 ·
#36668 · #36572 · #37340 · #35032 · #36985
PRs — #37149 · #37205 · #37313 · #37539 · #37442 · #37104 · #37319 · introduced by #35257
(669631d735)
Historic recurrences of this class — #35032 (2026-03) · #34110 (2025-12) · #29772 (2025-05) ·
#27101 (2024-01) · #24370 (2023-05) · #23764 (2023-01) · #22164 (2022-08)
Contract — specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the capability contract in specs/36351-block-editor-link-setting/contracts/allowed-blocks-capability-keys.md, then trace the listed editor entry points in suggestion.utils.ts, editor-extensions.ts, parser.utils.ts, dot-block-editor.component.ts, the slash menu, toolbar, and asset popover. Review the delivery templates VM_global_library.vm and static/storyblock/render.vtl alongside the SDK renderers. Done means the six live defects are addressed across the frontend paths and the contract is enforced rather than remaining documentation-only.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- angular, react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100