Skip to content

fix(block-editor): stop creating emoji nodes and heal the ones already stored - #37442

Open
rjvelazco wants to merge 17 commits into
mainfrom
issue-37340-block-editor-emoji-node-impl
Open

fix(block-editor): stop creating emoji nodes and heal the ones already stored#37442
rjvelazco wants to merge 17 commits into
mainfrom
issue-37340-block-editor-emoji-node-impl

Conversation

@rjvelazco

@rjvelazco rjvelazco commented Sep 7, 2026

Copy link
Copy Markdown
Member

Fixes #37340. PR 2 of 2 — the spec merged in #37434.

Typing ©, ® or inside linked text split the link into two anchors and lost the character on published pages. The cause is four lines of editor configuration; v1 chased it downstream into five renderers, Java and three npm packages.

13 files, over half of them tests. No VTL, no Java, no SDK, no version bump.

What was actually wrong

The emoji extension replaced any character Unicode classifies as an emoji with a bare emoji node — 1907 of the 1949 it catalogues, including 219 that render as ordinary typography (, , arrows, ). A bare inline atom inside a marked run ends that run, so a linked phrase became text(link) + emoji + text(link), persisted into the stored JSON.

Worse, the node stores a TipTap shortcode, never a character, and the table that resolves one ships inside an npm dependency of the editor. Nothing downstream can perform that lookup.

Four stories

Delivers
US1 All five node-creation paths closed. The issue names one
US2 Stored nodes heal to text on load
US3 The link sandwich rejoins the reported payload into one <a>, no re-save
US4 Emoji authoring works on every field, restricted or not

The picker, :copyright: and :) all keep working — only their output changes.

Found while doing it

A live Java defect this fixes for free. StoryBlockUtil.isTextContentEmpty counts only "text" nodes and feeds required-field validation at ESContentletAPIImpl.java:8180 — so a required Story Block field whose only content is one emoji currently fails to save as "empty." getCharCount and ES text extraction have the same blind spot. Healing the node to text fixes all three with no Java change.

A wrong assumption that survived three drafts. The spec claimed ProseMirror joins adjacent identical-mark text nodes on load. It does not — Fragment.fromJSON constructs directly and never reaches Fragment.fromArray. It looked true because the editor renders one <a> regardless. Unmerged, the heal would have stored three text(link) nodes and rendered three anchors where the defect produces two. The merge is now ours. Evidence in research.md R10.

The emoticon rule was eating a keystroke. TipTap marks the text-input event handled, so the space that triggered :) never reached the document. hi :) there now keeps its spacing.

A gate that could only misfire. emoji is not selectable in Allowed Blocks (getEditorBlockOptions() offers block nodes only, #37175), so isAllowed('emoji') was true only on unrestricted fields — restricting any block silently removed the emoji button and :). Nobody configured that.

Three things worth your attention in review

  1. emoji-heal.utils.ts — the sandwich rule is narrower than AC-015 requires. It matches on full mark-set equality, not just the link marks. It can only fire less often and it keeps the merge coherent, but it is a deviation from the written criterion.
  2. US4's Red was retroactive. The gate was removed before the tests existed; Red was confirmed by reverting the two toolbar files and re-running. The set discriminates correctly, but the TDD order was not followed. Noted in the commit and tasks.md rather than glossed.
  3. One pre-existing Block Editor renders blank on edit when Allowed Blocks is configured #37175 assertion is deliberately inverted — it required enableEmoticons: false on restricted fields.

Tests

259 passing, up from a 158 baseline. Jest only: no Java, DB, REST, renderer or build artifact is touched, and accepted ADR-0013 skips integration/Postman/Karate for core-web/** in the merge queue.

The 9 negative cases in emoji-heal.utils.spec.ts are the real deliverable of US3 — they are what stops the one permitted inference widening.

Still outstanding

Video

video.mov

🤖 Generated with Claude Code

rjvelazco and others added 6 commits September 7, 2026 15:13
Raised in review on PR #37434 by a reviewer, after the spec merged. The
spec rejected inheriting a neighbour's link mark on the grounds that it
infers one author's intent from one payload shape. That holds for
"inherit from a neighbour" in general. It does not hold for the specific
signature this defect produces: a BARE emoji node between two text nodes
whose link marks are equal on every attribute.

That shape is a fingerprint, not an editorial choice. Applying a link
over an existing emoji node marks the node too (verified), so an author
who links across a symbol produces a marked node, not a bare one. And
after this fix no typed symbol becomes a node at all, so the sandwich
cannot be newly created. The one construction that does reach it —
linking two runs separately around an already-converted symbol —
requires having hit this defect first, and so lives in the same legacy
population the heal repairs.

The heal now inherits the link mark for that signature only. ProseMirror
joins the resulting identical-mark run; the transform never merges nodes
itself. The reported payload therefore renders as a single <a> with no
re-save and no author action, which MEETS #37340's "already-split
content renders as a single link without requiring a re-save" criterion
instead of amending it. Two ACs on the issue still need amending, not
three.

research.md R8 had rejected this narrower rule partly on a false
premise — that ProseMirror already handled the case. It handles the
already-marked node, whose three text nodes match; it cannot join the
sandwich, whose middle node is bare by construction. R9 records the
correction, the counterexample that keeps "cannot be produced
deliberately" from being overstated, and why a wrong merge here is
visible and reversible rather than silent.

AC-015 states the rule; AC-016 asserts every case where it must not
fire — differing attrs, a non-text sibling, a block boundary, a sibling
without a link, and a node carrying its own marks. AC-016 is the
deliverable that keeps the single permitted inference from widening.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2's T003 existed to check one assumption before the heal was
written. The assumption was wrong, and it had been repeated in five
artifacts.

The spec claimed ProseMirror joins adjacent text nodes carrying
identical marks during normalization, and concluded "the heal never
merges nodes itself." Measured against the real schema in jsdom:
Node.fromJSON returns three separate text(link) nodes, and so does
setContent followed by getJSON. Fragment.fromJSON is
`new Fragment(value.map(schema.nodeFromJSON))` — it constructs directly
and never reaches Fragment.fromArray, which is the only place joining
happens.

The assumption survived three drafts because the editor RENDERS one <a>:
the DOM serializer emits a mark run as a single element however many text
nodes it spans. Document and DOM disagree, and only the DOM had been
looked at.

It matters because the stored JSON is what VTL and the SDKs consume, and
those emit one <a> per text node — the Gap B behavior #37340 documents.
An unmerged heal would store three adjacent text(link) nodes and render
three anchors where the defect currently produces two. It would have made
the reported symptom worse.

So the heal merges. Two constraints keep that from becoming a second
inference:

- Marks are already identical, so concatenating decides nothing. It is
  precisely what Fragment.fromArray does. The link sandwich remains the
  only place this change infers anything.
- The merge is scoped to inline arrays the heal actually touched. A
  document-wide pass would normalize identical-mark runs unrelated to
  this defect and break AC-017's identity guarantee.

AC-013 gains the merge as part of the heal's definition; AC-015 drops
"ProseMirror then joins the run"; AC-016 gains two negative cases —
differing marks never merge, and untouched arrays are returned as found.
No renumbering: every change folds into an existing criterion.

Evidence in research.md R10, which also records T004: jsdom's
isEmojiSupported() returns false, so the cdn.jsdelivr.net fallbackImage
is the default render there rather than an edge case. R3's two paste
defenses both stay.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes all five paths that mint an `emoji` node. The issue names one.

  1. appendTransaction — converted ANY emoji character on ANY change:
     1907 of the extension's 1949 catalogued characters, 219 of which
     render as ordinary typography. This also explains the toolbar
     picker, which already inserted a literal character that the hook
     converted straight back.
  2. :shortcode: input rule
  3. :) emoticon rule
  4. pasted :shortcode:
  5. parseHTML — not a rule, but pasting emoji HTML copied from another
     Block Editor field minted a node just the same.

Paths 2 and 3 keep working; only their output changes, from a node to
the literal character in the surrounding text node. Path 5 is closed by
neutralizing parseHTML AND by a transformPastedHTML that resolves the
span before ProseMirror parses. Both are needed: upstream renders two
HTML shapes, and dropping the parse rule alone leaves the fallbackImage
variant's <img> exposed for DotImage to claim as a dotCMS image pointing
at cdn.jsdelivr.net — worse than the bug being fixed. That shape is not
hypothetical; it is the default wherever isEmojiSupported() is false.

addProseMirrorPlugins returns the double-click plugin rather than an
empty array. Upstream bundles handleDoubleClickOn with the conversion,
and only the conversion is the defect; the gesture still selects a
stored node in one click.

Two fixes found while writing the tests:

- The emoticon rule swallowed the space that triggered it. TipTap runs
  input rules from handleTextInput, and a rule producing steps marks the
  event handled, so the typed space never reached the document. It is
  now re-inserted — an author typing "hi :) there" keeps their spacing.
- enableEmoticons no longer gates on has('emoji'). `emoji` is not
  selectable in Allowed Blocks (getEditorBlockOptions offers block nodes
  only, #37175), so that gate was true only on fields with NO
  restriction — silently removing :) from every field that restricted
  anything else. Nobody configured that. Closes AC-008 for the rule;
  the toolbar half follows in US4.

74 tests: the affected class sampled across reported symbols, 20
text-presentation characters and multi-codepoint cases; start, middle
and end of linked text; four paste shapes; both emoji HTML shapes; and a
guard proving a schema without the registration still cannot parse a
stored node — the reason the registration stays.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turns stored `emoji` nodes back into text on the editor's parse path, so
the character returns to every consumer at once.

The node is the wrong shape for stored content: it holds a TipTap
shortcode rather than a character, and the table that resolves one ships
inside an npm dependency of the editor. Nothing downstream can perform
that lookup. Converting it to `text` fixes, with no change outside this
library: VTL dropping the character, the SDKs rendering an unknown
block, Elasticsearch never indexing it, `StoryBlockUtil.getCharCount`
counting zero, and `isTextContentEmpty` reporting an emoji-only Story
Block field as "empty" so required-field validation rejects it.

The rule is `emoji(marks) -> text(same marks, character)`. Marks are
preserved, never invented — a transform that guessed could alter
documents that never had this defect, silently and irreversibly.

One exception, and it is the only inference in this change: a BARE node
between two text nodes carrying identical mark sets that include a link
inherits those marks. That shape is a fingerprint of this defect, not an
editorial choice — applying a link over a node marks the node too, and
after the fix no typed symbol becomes a node at all. Nine negative cases
are asserted individually, which is what keeps the inference from
widening: differing href, target or aria-label; a hardBreak sibling; a
sibling with no link; both block boundaries; a node carrying its own
marks; and an inline array the heal never touched.

Requiring the FULL mark sets to match, rather than only the link marks,
is narrower than AC-015 demands. It can only fire less often, and it
keeps the merge coherent — a healed node whose marks differed from its
neighbours' would not merge, leaving the run split anyway.

The merge is ours, per research.md R10. ProseMirror does not join
identical-mark runs on any load path, and unmerged runs would render as
three anchors downstream where the defect produces two.

Also updates one #37175 assertion, deliberately inverted: it required
`enableEmoticons` to be false on a restricted field. `emoji` is not
selectable in Allowed Blocks, so that gate was true only on fields with
no restriction — it could only ever misfire.

Full suite: 255 tests, up from a 158 baseline.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…bar (#37340)

Completes AC-008. The extension half landed with US1; this is the
toolbar half.

`emoji` is not selectable in Allowed Blocks — the option list comes from
getEditorBlockOptions(), which offers block nodes only, and
link/emoji/youtube were excluded by #37175 itself. So
isAllowed('emoji') was true ONLY on a field with no restriction at all:
restricting any block silently removed the emoji button, and nobody
configured that. It is a gate that could only misfire.

Removing it makes showInsertGroup dead — the insert group now always
holds at least the emoji button — so the computed and its two template
guards go with it rather than being left as always-true conditions.

Visible consequence, and the intended one: a heavily restricted field
that previously showed no insert group now shows one containing the
emoji button.

The Red for this story was verified retroactively rather than up front:
the gate was removed before the tests existed, so Red was confirmed by
reverting the two toolbar files and re-running. The set discriminates
correctly — the two restricted-field cases fail against the old gate
while the unrestricted and explicit-emoji cases pass, which is exactly
the boundary this AC moves. The TDD order was not followed here; noting
it rather than glossing it.

The toolbar store is stubbed with a Proxy rather than a hand-listed
object. The component reads a wide surface of mark and block signals,
and enumerating them would make the spec a maintenance burden for every
future button while testing none of them.

Full suite: 259 tests, 19 suites.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes AC-010. The node/action inventory still described the old
gating and said nothing about the node no longer being created.

Adds a section beside the AIContent one covering the two reasons the
registration is load-bearing, the heal that converts stored nodes on
load, the server-side blind spots it fixes with no Java change, and two
traps: parseHTML is deliberately empty, and assertions belong on the
JSON rather than the editor DOM — that disagreement hid a wrong design
assumption through three drafts.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 1m 59s —— View job


Review — PR #37442 (re-review on synchronize)

  • Read the diff since last review
  • Emoji extension + heal utils
  • editor.component emit/load path
  • slash-menu + toolbar changes
  • Post review

Focused this pass on the emit-on-heal path, the widened link-sandwich/run logic, the : autocomplete command/filterEmojis, and the shared slash-menu changes. Traced the branches rather than trusting the prose.

New Issues

No issues found.

Things I specifically tried to break and couldn't:

  • healInline run merge over unrelated nodes (emoji-heal.utils.ts:324). mergeAdjacentText runs across the whole healed array whenever any emoji was converted, so in principle it could also collapse two pre-existing adjacent identical-mark text nodes that had nothing to do with the emoji. This is benign: identical marks ⇒ identical rendering, it only fires on arrays that already changed, and it's what ProseMirror's own inline doc model does internally anyway. The editor never emits adjacent identical-mark text nodes in the first place (its doc model joins them), so the only way to hit it is hand-crafted/API/migrated JSON that also carries an emoji. Not a regression — and it's the documented tradeoff behind AC-019. Noting it only so the next reader doesn't re-discover it as a "bug."
  • Boundary walk consistency (emoji-heal.utils.ts:310-317). boundaryOf walks the original nodes array while the .map builds the healed one — correct, since bare-convertibility is independent of heal order, and every symbol in a ©® run resolves to the same two boundary text nodes, so all inherit one mark set and mergeAdjacentText collapses them to a single <a>.
  • "Never invent marks" holds for the non-sandwich case (emoji-heal.utils.ts:319-321). A bare emoji next to a marked text node (e.g. text(bold) emoji) fails the isText(next) / full-mark-equality test, heals to unmarked text, and marksEqual([], [bold]) keeps them unmerged — the symbol correctly does not absorb the neighbour's bold.
  • Emit gating (editor.component.ts:532, 561). Both load branches emit only when healedHtml !== parsed / healed !== preserved, and healEmojiNodes/healEmojiHtml return the same reference when nothing changed, so a document with no emoji node is never dirtied. emitHealedValue defers to a microtask and guards editor.isDestroyed — no NG0100, no emit into a torn-down editor. The syncCharacterStatsFromEditor call before the emit is necessary because emitUpdate:false skipped the stats refresh (well-caught, emoji-heal FINDING 5).
  • enableEmoticons: true un-gating (editor-extensions.ts:181). The old has('emoji') gate was true only on unrestricted fields (emoji isn't an Allowed-Blocks option), so restricting any block silently dropped :). Removing the gate is the correct fix, not a new leak — nothing here creates an emoji node regardless.
  • Shared slash-menu changes (slash-menu.types.ts, .component.html). iconKind defaults to the material path via the @else branch, so every existing block row is byte-for-byte unchanged; truncate on the label span is inert for two-word block labels. SlashMenuService.update() gaining a doc-comment only — no behavioural change.

Consistent with ADR-0013's frontend-only skip: no Java/DB/REST/renderer surface touched. Jest-only, as stated.
· Branch: issue-37340-block-editor-emoji-node-impl

@rjvelazco rjvelazco changed the title fix(block-editor): stop creating emoji nodes and heal the ones already stored (#37340) fix(block-editor): stop creating emoji nodes and heal the ones already stored Sep 8, 2026
rjvelazco and others added 2 commits September 8, 2026 13:10
…37340)

Found in manual testing. The heal ran and the © rendered correctly, but
opening the field and hitting Publish saved the ORIGINAL value — the
repair only stuck if the author also typed something first.

`loadContent` calls setContent with `emitUpdate: false`, deliberately: a
host push or a reactive-forms write must not look like an author edit.
The consequence went unnoticed because the healed document was sitting
in the editor while the form control still held the unhealed string, and
the editor renders both identically.

Now the load path emits when — and only when — the heal actually
rewrote something. `healEmojiNodes` returns the same reference for an
untouched document, so a field carrying no `emoji` node stays pristine
and is never marked dirty just by being opened. That guard is the whole
reason this is safe to do.

Deferred to a microtask: `writeValue` is one of `loadContent`'s callers,
and calling onChange synchronously inside it trips NG0100.

`onUpdate` and the load path now share one `emitValue`, so both emit the
identical shape. They differed before, which is how this slipped
through.

Also closes a real coverage gap. `emoji-heal.utils.spec.ts` tests the
transform as a pure function, which cannot fail for the reason this bug
existed — nothing asserted that the heal reached the host at all. The
two new specs assert at the call site: a broken document emits a healed
value with no author edit, and a clean one emits nothing. Verified
discriminating by disabling the emit — the first fails, the second still
passes.

Spec amended: the "reaches storage on the author's next save" line was
true only with an edit, and said so misleadingly.

261 tests, 19 suites.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ode-impl' into issue-37340-block-editor-emoji-node-impl
…37340)

Reverses a v2 non-goal. Suppressing the emoji node left `:copyright:`
working but undiscoverable — nothing tells an author the shortcode
exists. The menu is its affordance, so the rule and its discovery ship
together.

Small because almost nothing is new. Reused verbatim from upstream: the
`:` trigger, EmojiSuggestionPluginKey so the session cannot collide with
the slash menu's, and the `allow` guard. Reused from this lib:
SlashMenuService for the dropdown, positioning and keyboard navigation —
`open()` already takes its items as an argument, so no new component.

Replaced: upstream's `command`, which inserted an emoji NODE. Leaving it
would have reopened the defect through the menu itself. Ours inserts the
character and keeps upstream's overrideSpace handling.

Written from scratch: `items`. Upstream ships no default and never has,
which is what the old `items: () => []` really was — a blank filled with
nothing, not a working menu switched off.

Two ranking defects, both caught by strengthening tests that were
initially weak (one was tautological):

- `:rocket:` returned `:astronaut:` first. A tag prefix was ranked equal
  to a shortcode prefix, and astronaut tags "rocket". Now four tiers:
  exact name/shortcode, name/shortcode prefix, tag prefix, substring.
- A query of `smi` labelled rows `:grinning_face_with_closed_eyes:`.
  Rows used `shortcodes[0]`; `smile` ships as
  ["grinning_face_with_closed_eyes", "smile"]. Now labelled by `name`,
  which is also what attrs.name carries.

Shared-component changes, called out because they affect `/` rows too:

- The slash menu's label span gains `truncate`. Long shortcodes are far
  wider than the fixed w-72 dropdown and were giving the whole list a
  horizontal scrollbar. Invisible for block rows, whose labels are two
  words. The description span was already truncated; the label had been
  missed.
- BlockItem gains `iconKind?: 'material' | 'glyph'`, defaulting to
  material. Glyph rows render bare — no bordered box and no
  `material-symbols-outlined`, which sets a font-family and variation
  settings built for Material's own ligature names. `leading-none` is
  what actually centres the glyph.

Explicit flag rather than sniffing the string for an emoji: this is
shared UI, and the next reader should see intent, not a regex.

Coverage: five rows capped, tier ranking, labelling, and — the one that
matters — the REAL command driven through the live plugin via a
recording menu-service stub, asserting a character lands and no `emoji`
node appears. Simulating the insert would have tested the assertion
rather than the code.

Worth knowing for anyone writing more of these: @tiptap/suggestion's
plugin view declares `update` as async, so its render callbacks land in
a microtask. A synchronous assertion sees the plugin state updated but
the menu not yet opened, which reads as a false failure. Hence
flushSuggestion().

Spec amended to v2.2 with AC-024 through AC-028, so the record does not
say "deliberately not done" beside code that does it.

270 tests, 19 suites.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco

Copy link
Copy Markdown
Member Author

Added by Claude Code, on @rjvelazco's behalf.

Added: the : emoji autocomplete (a90b308d7a)

Scope addition after manual testing, folded in deliberately rather than split out. Spec amended to v2.2 in the same commit — a spec saying "deliberately not done" beside code that does it is worse than either alone.

Why it belongs here: suppressing the emoji node left :copyright: working but undiscoverable. The shortcode rule was already in scope (AC-006); this is its affordance.

Small because almost nothing is new

Reused verbatim from upstream: the : trigger, EmojiSuggestionPluginKey so the session can't collide with the slash menu's, and the allow guard.

Reused from this lib: SlashMenuService for the dropdown, positioning and keyboard nav — open() already takes its items as an argument, so no new component.

Replaced: upstream's command, which inserted an emoji node. Leaving it would have reopened the defect through the menu itself.

New: items only. Upstream ships no default and never has — which is what the old items: () => [] actually was. A blank filled with nothing, not a working menu switched off.

⚠️ Two shared-component changes

These affect / rows too, so they need a look:

  • slash-menu.component.html — the label span gains truncate. Long shortcodes are wider than the fixed w-72 dropdown and were giving the whole list a horizontal scrollbar. Invisible for block rows, whose labels are two words. The description span was already truncated; the label had been missed.
  • slash-menu.types.tsBlockItem gains iconKind?: 'material' | 'glyph', defaulting to material. Glyph rows render bare: no bordered box, no material-symbols-outlined, which sets a font-family and variation settings built for Material's own ligature names.

Explicit flag rather than sniffing the string for an emoji. This is shared UI; the next reader should see intent, not a regex.

Two ranking defects, both caught by fixing weak tests

My first three specs here were poor — one was tautological. Strengthening them exposed real bugs:

  • :rocket: returned :astronaut: first. A tag prefix ranked equal to a shortcode prefix, and astronaut tags "rocket". Now four tiers: exact name/shortcode → name/shortcode prefix → tag prefix → substring.
  • smi labelled rows :grinning_face_with_closed_eyes:. Rows used shortcodes[0]; smile ships as ["grinning_face_with_closed_eyes", "smile"]. Now labelled by name, which is also what attrs.name carries.

The assertion that matters

AC-025 drives the real command through the live plugin via a recording menu-service stub, asserting a character lands and no emoji node appears. Simulating the insert — which is what my first draft did — would have tested the assertion rather than the code, and this command is precisely the piece that replaced upstream's node-creating one.

A gotcha for anyone writing more of these

@tiptap/suggestion's plugin view declares update as async, so its render callbacks land in a microtask after the dispatch. A synchronous assertion sees the plugin state updated but the menu not yet opened, which reads as a false failure. Hence flushSuggestion().

Also in this push

b2f086c326 — the heal now emits when it rewrites something, so a plain Save persists the repair. Previously it only stuck if the author also typed: loadContent uses emitUpdate: false, so a healed document sat in the editor while the form control held the unhealed string, and the editor renders both identically. Gated on healEmojiNodes having actually changed the document, so a field with no emoji node stays pristine and is never dirtied by being opened.

270 tests, 19 suites. AC-001 through AC-028.

…7340)

Closes T050. Convergence found AC-016 named three non-text neighbours
that must stop the link rule firing — a line break, another symbol, an
image — and tested only the line break. Writing the missing two turned
one of them into a rule change rather than a test.

Two symbols side by side inside one link — `©®` typed together — was a
payload the rule left behind. Each node's inner neighbour was the OTHER
symbol rather than text, so an immediate-siblings-only check never
matched. The fingerprint is identical and the run length is incidental,
so the rule now scans outward past adjacent bare convertible symbols and
matches on the run's boundaries.

Anyone who typed two legal marks together inside a link before this fix
has exactly that stored, which makes it arguably more common than the
line-break case that was already covered.

The scan steps over bare convertible symbols ONLY. Anything else ends
the run and, not being a text node, stops the rule: a hardBreak, a
dotImage, a symbol carrying its own marks, a symbol whose name does not
resolve. So a symbol beside an inline image heals to bare text and stays
outside the link — a picture next to a symbol says nothing about whether
the symbol belonged to it. That was already the behaviour; it now has a
test.

Four cases added: two symbols between identical links (joins), two
between different links (does not), a symbol beside a dotImage (does
not), and a run broken mid-way by a hardBreak (does not) — the last
proving the scan cannot tunnel through anything but symbols hunting for
a text node to match.

Spec amended to v2.3. AC-015 now describes a run; AC-016's non-firing
list is stated in terms of the run's boundaries.

Documentation drift fixed in libs/new-block-editor/CLAUDE.md, two of
which the docs-converge hook would otherwise have raised:

- The toolbar section still described `showInsertGroup`, deleted when
  the emoji Allowed Blocks gate went. Recorded along with its visible
  consequence: a heavily restricted field now shows an insert group it
  previously collapsed.
- The heal's exception was described as a single bare node.
- Added a section on `BlockItem.iconKind` and the truncated row label,
  since the `:` autocomplete introduced both to shared slash-menu
  surface.

274 tests, 19 suites.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjvelazco and others added 3 commits September 8, 2026 15:15
Closes T051, which turned out to be a defect rather than a verification.

`normalizeEditorContent` treats any non-JSON string value as HTML.
dotCMS does not store Story Block fields that way, but a host embedding
the editor can pass HTML — and R3 assumed transformPastedHTML covered
the emoji-span problem. It does not cover this path: it only runs on
paste.

Measured by loading each rendered shape through the component with
parseHTML neutralized. The span carrying a character degrades fine — its
own text survives. The span wrapping the fallbackImage does not: the
inner <img src="cdn.jsdelivr.net/…"> is left exposed and DotImage claims
it, producing node list ["paragraph","text","dotImage","paragraph",
"text"]. A legal symbol silently became a third-party image embed —
worse than the defect being fixed, and exactly the failure R3 predicted
for a parseHTML-only defense while getting its reach wrong.

The span rewrite moves out of the extension into healEmojiHtml in
emoji-heal.utils.ts, beside its JSON sibling, and is called from both
transformPastedHTML and the component's HTML-string branch. Returns the
input unchanged when no emoji span is present, so the common case
allocates nothing.

Also closes T054, which found the opposite kind of problem: an exported
fixture nothing asserted. REPORTED_PAYLOAD_HEALED claimed
"Copyright © All rights reserved" where the heal produces
"Copyright ©All rights reserved" — the source is "dotCMS Copyright " plus
the node plus "All rights reserved", and the heal invents no whitespace.
Corrected, and AC-021 now asserts whole-document equality against it
rather than checking node count, text and marks individually, which
passes even when some other part of the document changed.

T052 is covered at the schema level by AC-020, which already asserts a
schema without the registration cannot parse a stored emoji node — the
mechanism by which the legacy editor drops it. The visual confirmation
is pre-existing behaviour and stays manual rather than being claimed.

Worth recording how all of this surfaced: R3 reached the right
conclusion about the danger and the wrong one about its reach. Three
design errors in this feature now — the ProseMirror join, the
tag-vs-shortcode ranking, and this — and every one was found by testing
the claim rather than re-reading the reasoning.

277 tests, 19 suites.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes T053 and T045. Built from the developer's own plan — the seeding
script, the context video, and their six cases — plus six gaps it did
not cover. 31 cases, all Manual, all Not Run Yet.

Their six cases map cleanly onto TC-001 through TC-016 and are all
correct, including the two that look contradictory but are not:
`link + emoji + emoji` with no trailing text keeps the symbols OUT of
the link, while `link + emoji + emoji + link` on the same URL pulls them
in. The difference is whether the run has a text node on both sides.

What was missing:

- The no-emoji control (TC-019, TC-020). The highest-risk case on the
  page and not about emoji at all: a heal that rewrites content
  unrelated to this defect is worse than the defect. TC-020 in
  particular checks that merely OPENING an untouched field raises no
  unsaved-changes prompt.
- Open and Publish without typing (TC-011). Their case 2 says "on save"
  without saying whether an edit happened, and that distinction was a
  real bug until b2f086c.
- The `:` autocomplete (TC-006 – TC-010), new in this PR. Their list
  covers the inline shortcode but not the menu.
- Recursion into headings, lists, blockquotes and table cells (TC-021).
  Every case on their list is paragraph-level.
- Restricted-field behaviour (TC-022, TC-023), including the visible
  consequence that an insert group now appears where it used to
  collapse.
- The accessibility pass itself (TC-027, TC-028) — the customer's
  actually reported symptom.

Also carries the instruction that matters most for reading results:
check the stored JSON, not the rendered page. The editor draws a mark
run as one <a> however many text nodes span it, and that disagreement
hid a wrong design assumption through three drafts.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's `format-test` goal failed on:

  libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.spec.ts
  libs/new-block-editor/src/lib/editor/testing/editor.testing.ts

Formatting only, no behaviour change. 277 tests and lint unchanged.

Cause is a gap in my own workflow rather than anything in the repo:
lint-staged runs `nx format:write` over STAGED files only, so edits made
to a file after the commit that formatted it escape the hook entirely.
Both files were formatted when first committed and edited again
afterwards.

`pnpm nx format:check` reproduces it locally in seconds and would have
caught this before the push. Worth running alongside test and lint from
now on, since the two are not equivalent — lint passed on both files.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An independent session reviewed the range and found seven issues. I
verified each against the source before agreeing; six were real and one
of its examples was wrong. Decisions recorded in review-decisions.md.

1. HEAL HAD NO ERROR BOUNDARY — the worst of the seven.

`attrsEqual(a = {}, b = {})` used default parameters, which fire only on
`undefined`, so a stored mark with "attrs": null reached
Object.keys(null) and threw. Separately "marks": {} passed the `?? []`
guard and died on `.some`. Both are reachable: data-model.md records
that hand-crafted JSON arrives through the Contentlet API because no
server-side Story Block validation exists.

The throw propagated out of writeValue, setContent never ran, and the
field rendered EMPTY over intact stored JSON — the #37145 mechanism this
spec cites twice. Arriving at that through the repair would be a poor
joke.

Fixed structurally with asMarks/asAttrs coercion so malformed shapes
cannot throw, plus a try/catch in healEmojiNodes returning the input
untouched. Fails closed at its own boundary rather than making every
caller defensive — the precedent is content-match.utils.ts.

2. THE FAST-PATH GUARD WAS NARROWER THAN ITS OWN SELECTOR.

`html.includes('data-type="emoji"')` is a literal double-quote match,
but querySelectorAll runs post-parse and matches data-type='emoji' and
DATA-TYPE="Emoji" too. Those skipped the guard entirely, reopening the
T051/R11 defect two commits after fixing it. Now a tolerant regex, kept
as a fast path rather than deleted — deleting it puts a DOMParser on
every field load and every paste.

3. THE HEAL REACHED INTO UNKNOWN-NODE CONTENT, and my ordering comment
was wrong in the opposite direction from how I wrote it.

preserveUnknownBlockNodes swallows an unknown node into
attrs.originalNode, documented as inert and byte-for-byte; the mark pass
skips dotUnsupportedBlock to honour that. Healing first rewrote emoji
nodes inside a customer's custom block before the payload was stashed,
so the "original" restored on save was not the original. The heal now
runs LAST, which makes the payload structurally unreachable rather than
relying on a name list staying in sync.

4. `/ :sm` opened two suggestion sessions against one dropdown slot.
Flagged by me while drafting and then not done. The emoji session now
yields when a slash session is active.

5. The healed emit dropped charCount/wordCount/readingTime.
withDocStats bails at `chars <= 0` and nothing had synced the count,
because this path sets content with emitUpdate: false. Stats are now
synced before emitting.

6. The HTML load path healed but never emitted, so "emitted as soon as
the heal rewrites something" held on the JSON path only.

7. A bare `:` after a space opened a menu of five arbitrary emoji.
filterEmojis now returns nothing for an empty query. The reviewer's
`Note:` and `10:30` examples do NOT trigger — allowedPrefixes defaults
to [" "], so those colons follow a letter and a digit — which narrows
the repro rather than dismissing it.

REJECTED: the claim that inheriting the full mark set is wider than
AC-015. It is the opposite — requiring both boundaries' whole sets to
match fires strictly less often than comparing href alone, and
inheritance carries the set that matched. Narrower gate, wider payload,
one decision. Now covered by a two-mark fixture either way.

Fixing 4 and 7 exposed two more of my own bugs, both caught by tests:
suppressing onStart left the menu permanently shut, because update()
never touches isOpen; and my recording test double omitted isOpen, so
the throw was swallowed by Suggestion's async plugin view and looked
exactly like "the menu never opened".

Also adds typeTextSlowly. typeText dispatches every character
synchronously, batching the async plugin updates into a state real
typing cannot produce — one menu spec was asserting against that
artifact.

Coverage added per the agreed split: the two-mark sandwich, links
differing in rel and title, an own-marks boundary, an unresolvable-name
boundary, one-sided marks, and malformed stored JSON.

284 tests, 19 suites. Full local gate now run before pushing — affected
tests, affected lint, and format:check. The two dotcms-block-editor
failures (karma missing locally, a pre-existing module-boundary lint
error) reproduce with this work stashed.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

Closes the residual gap peer re-verification found in finding 2 — the
same defect shape, in the other direction.

The guard `/data-type\s*=\s*['"]?emoji/i` is case-insensitive. The
selector `span[data-type="emoji"]` is not: CSS matches attribute VALUES
case-sensitively and only attribute NAMES case-insensitively. So
`data-type="EMOJI"` passed the guard, paid for the DOMParser, matched
zero spans, and returned the HTML untouched with the jsdelivr <img>
still exposed for DotImage to claim.

Not a regression — the old `includes` guard rejected the same input
earlier and more cheaply — but the guard and the selector disagreeing
about case IS finding 2, so leaving it would have been leaving the bug
half-fixed. The CSS `i` flag costs nothing and makes the regex's `/i`
honest.

Verified non-vacuously: with the flag removed the new uppercase-value
spec fails. Four specs added covering the attribute-name and
attribute-value case variants plus the no-emoji passthrough.

Also documents a trap the review surfaced: SlashMenuService.update()
refreshes items, anchor and command but never touches isOpen, so calling
it against a closed dropdown silently does nothing visible. Its docblock
now says so, and says that a caller whose first event can arrive while
the menu is shut must call open(). Nothing in the API shape hinted at
it, and it cost this feature an afternoon.

review-decisions.md records the re-verification, the coverage movement
(heal branch 75.00% → 80.24%, functions 100%), and one deferral: typing
the test doubles against the service interface so the compiler catches
an omitted method. Three stubs diverged from the real interface during
this work, each presenting as absence rather than error.

288 tests, 19 suites. Affected tests, lint and format:check all clean.

Refs #37340

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco

Copy link
Copy Markdown
Member Author

@claude review this code

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @rjvelazco's task in 2m 53s —— View job


Review — PR #37442

  • Read the emoji extension and heal utils
  • Read editor.component.ts load/emit path
  • Read slash-menu changes (: autocomplete, shared component changes)
  • Read toolbar changes
  • Review tests for gaps
  • Post review

I focused this pass on what's landed since the previous review on 2026-09-07: the : emoji autocomplete (dot-emoji.extension.ts), the widened link-sandwich rule and HTML-load heal in emoji-heal.utils.ts, the case-insensitive span selector, the two shared slash-menu component changes, and the toolbar ungating.

New Issues

No issues found.

Specific things I checked and why they hold up:

  • Suggestion collision guard (shouldSuppress, dot-emoji.extension.ts:340-350). It reads SuggestionPluginKey.getState(...) — the generic key exported by @tiptap/suggestion — to detect whether the slash menu is active. That's correct because slash-command.extension.ts never overrides pluginKey on its own Suggestion() call, so it is the default SuggestionPluginKey instance. The emoji suggestion uses its own EmojiSuggestionPluginKey, so the two sessions can coexist in the plugin state without colliding, and shouldSuppress correctly detects the slash session even though it's a different plugin key.
  • Ranking tiers in filterEmojis (dot-emoji.extension.ts:257-301). Tiers 1–4 (exact → prefix → tag-prefix → substring) are checked in the right precedence order and short-circuit with else if, so a name/shortcode-prefix match can't be demoted by a tag match. Stable sort by (tier, table-index) — matches the described :rocket: / :astronaut: fix.
  • The link-sandwich run-walk (emoji-heal.utils.ts:238-251, 279-325). boundaryOf walks the original nodes array (not the partially-healed output), which is correct since bare-emoji-convertibility doesn't depend on healing order. For a multi-symbol run (©®), both ends resolve to the same boundary text nodes, so every symbol in the run gets previous.marks, and mergeAdjacentText (run over the full healed array) collapses them into one node — one <a> for a multi-symbol run inside a link, as described.
  • healEmojiHtml case sensitivity (emoji-heal.utils.ts:165-201). The fast-path regex guard and the querySelectorAll('span[data-type="emoji" i]') selector are now both case-insensitive on the attribute value, closing the gap where data-type="EMOJI" passed the guard but matched zero spans.
  • Emit-on-heal (editor.component.ts:483-588). emitValue is now shared between onUpdate and the healed-load path, and both the JSON and HTML load branches gate the emit on healed !== preserved / healedHtml !== parsed — so a document with no emoji node is never dirtied, and a healed one now survives a plain Save. emitHealedValue correctly guards on editor.isDestroyed inside the deferred microtask.
  • Toolbar ungating (toolbar.component.ts/.html). No leftover showInsertGroup/isAllowed('emoji') references anywhere in the toolbar files — the removal was clean, not just visually hidden.
  • Shared slash-menu changes (slash-menu.types.ts, slash-menu.component.html). iconKind is explicit and defaults implicitly to the material rendering path (the @else branch), so every existing block row is unaffected; the emoji-only truncate addition is on a <span> that was previously untruncated and is otherwise inert for two-word block labels, matching the stated rationale.

One thing worth double-checking against real usage rather than a logic read: SlashMenuService.select() calls this.editor?.view.focus() before invoking commandFn, and the emoji command reads editor.view.state.selection.$to.nodeAfter — if focusing synchronously changes selection in some browser/DOM combination that Jest's simulated environment wouldn't catch, overrideSpace could read stale state. I didn't find evidence this is a real risk (focus doesn't move the selection in ProseMirror), but it's the one path in this diff that isn't purely reasoned-through by tests, since jsdom's focus semantics aren't identical to a real browser's.

No Java/DB/REST surface is touched, consistent with ADR-0013's frontend-only skip for this PR.
· Branch: issue-37340-block-editor-emoji-node-impl

…37340)

The `:` autocomplete entered the spec at v2.3, after /speckit-tasks had
generated the task list, so it shipped with no approval gate and no Red
gate on record. Convergence phase 9 raised that; T056 closes it.

Running Red retroactively found two tests that passed against an
extension that does nothing:

  - AC-024 read `extension.options.suggestion` — upstream's own options
    object, present whether or not we register a plugin. It asserted
    TipTap's defaults survived, not that our trigger works.
  - AC-025 relied on `rows.every(...)`, and `[].every()` is true. It
    passed *because* the filter returned nothing.

Both strengthened: the probe now fails 9 of 9 instead of 7 of 9.

AC-028 had the same shape — it asserted on `rocket`, whose name and
first shortcode are the same string, so it held whichever source the
code read. Added the `smile` case the criterion actually names, where
the first shortcode is `grinning_face_with_closed_eyes`.

Also labels all nine assertions with the criteria they discharge, and
retitles the suite, which still said "(draft)".

TDD order was not followed for this story — the implementation was
drafted first at the developer's request. Recorded in research.md R13
rather than glossed, as T039 did for US4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rjvelazco
rjvelazco enabled auto-merge September 9, 2026 00:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Block Editor splits a link into two anchors when ©, ®, or ™ appears inside the linked text

1 participant