Content
View differences
Updated by Kabiru Mwenja about 2 months ago
**Type:** Code Maintenance
**Category:** Accessibility (a11y)
**Related:** ##71111
## Description
The parent WP (#71111 ) had Our `ExternalLinksController` (Stimulus) uses a MutationObserver to skip \`aria-describedby\` process links on the page — adding `target="_blank"`, `rel="noopener noreferrer"`, `aria-describedby` for screen readers, and optionally rewriting `href` to `/external_redirect` for link capture. This works well for server-rendered pages and CKEditor content, but is fundamentally incompatible with ProseMirror-based editors (BlockNote/TipTap).
ProseMirror maintains its own document model and uses an internal `DOMObserver` that watches every attribute change inside BlockNote's its contenteditable because mutating region. When an external observer (our controller) modifies link attributes from outside in the DOM, ProseMirror detects the change, re-parses the DOM against its schema, and re-renders — which creates new DOM nodes that trigger our observer again. This causes a feedback loop that freezes the browser.
Three categories of conflict exist:
1. **Attributes not in the Link mark schema** (e.g. `aria-describedby`): ProseMirror strips them on re-render because they're not recognized, producing a new node without the attribute. Our observer re-adds it. Infinite loop.
2. **Redundant writes to schema-recognised attributes** (e.g. `target`, `rel`): Even when the value is already correct, the DOM write triggers an ProseMirror's DOMObserver. Idempotency guards (only write when the value differs) break this loop, but it's fragile — it depends on ProseMirror preserving those attributes faithfully on re-render.
3. **`href` rewrite to `/external_redirect`** (capture mode): This changes the actual link destination in ProseMirror's document model. With Yjs collaboration, the shared document has the original href, but the local model now has the redirect URL. Yjs corrects it back, ProseMirror re-renders with the original, our controller rewrites it again — infinite re-render loop. This means screen Even without Yjs, persisting redirect URLs into the document model corrupts the document content.
We've introduced a `ProseMirrorExternalLinksController` subclass that mitigates categories 1 and 2 (skips `aria-describedby`, adds idempotency guards for `target`/`rel`). Category 3 (href rewrite with capture enabled) remains unresolved — enabling external link capture with BlockNote still freezes the browser.
## Current impact
- **`aria-describedby`**: Screen reader users don't editing a collaborative document won't hear "opens "opens in new tab" when focused on an tab" for external links. The hint is present in the read-only view.
- **External link capture**: Cannot be enabled for collaborative documents using BlockNote. The `/external_redirect` href rewrite conflicts with ProseMirror's model-driven rendering and Yjs collaboration, causing the browser to freeze.
## Proposed solution
Instead of manipulating the DOM from outside ProseMirror (Stimulus MutationObserver), handle these behaviors through ProseMirror's own plugin/decoration pipeline. ProseMirror natively manages decorations, so there's no mutation loop — ProseMirror owns the attributes.
A single ProseMirror plugin could handle all three concerns:
```typescript
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
const externalLinksPlugin = new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
node.marks
.filter(mark => mark.type.name === 'link')
.forEach(mark => {
const attrs: Record<string, string> = {};
// a11y: screen reader hint for blank-target links
if (mark.attrs.target === '_blank') {
attrs['aria-describedby'] = 'open-blank-target-link-description';
}
// External link capture: rewrite href for display only
// (decoration doesn't change the document model)
const href = mark.attrs.href;
if (isExternal(href) && captureEnabled) {
attrs['href'] = `/external_redirect?url=${encodeURIComponent(href)}`;
}
if (Object.keys(attrs).length > 0) {
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, attrs)
);
}
});
});
return DecorationSet.create(state.doc, decorations);
},
},
});
```
Key advantage of the decoration approach for href rewriting: decorations are a **view-layer concern** — they modify what the user sees without changing the underlying document model. The original href stays in the Yjs document, so there's no conflict with collaboration, and the document content isn't corrupted with redirect URLs.
This plugin could be passed to BlockNote via `_tiptapOptions.extensions` (wrapping it in a TipTap extension). Note that `_tiptapOptions` is an internal/undocumented BlockNote API — it works and is tested, but isn't part of the public contract.
## Acceptance criteria
- External links inside BlockNote's contenteditable have `aria-describedby="open-blank-target-link-description"`
- External link capture (href rewrite to `/external_redirect`) works inside BlockNote without freezing
- The document model retains original hrefs (redirect is display-only via decorations)
- No browser freeze when pasting content with multiple links (existing regression test should keep passing)
- Body-level `ExternalLinksController` behavior unchanged
- Existing a11y tests in `spec/features/a11y/external_links_spec.rb` still pass
## Related files
- `frontend/src/stimulus/controllers/prosemirror-external-links.controller.ts` — current workaround subclass
- `frontend/src/stimulus/controllers/external-links.controller.ts` — base controller
- `frontend/src/react/components/OpBlockNoteEditor.tsx` — BlockNote editor setup, where plugin would be registered
- `frontend/src/elements/block-note-element.ts` — custom element, registers the editor. Stimulus controller in shadow DOM
- `modules/documents/spec/features/external_links_in_block_note_spec.rb` — feature tests including freeze regression
- `spec/features/a11y/external_links_spec.rb` — body-level a11y tests
**Category:** Accessibility (a11y)
**Related:** ##71111
## Description
The parent WP (#71111 ) had
ProseMirror maintains its own document model and uses an internal `DOMObserver` that watches every attribute change
Three categories of conflict exist:
1. **Attributes not in the Link mark schema** (e.g. `aria-describedby`): ProseMirror strips them on re-render because they're not recognized, producing a new node without the attribute. Our observer re-adds it. Infinite loop.
2. **Redundant writes to schema-recognised attributes** (e.g. `target`, `rel`): Even when the value is already correct, the DOM write
3. **`href` rewrite to `/external_redirect`** (capture mode): This changes the actual link destination in ProseMirror's document model. With Yjs collaboration, the shared document has the original href, but the local model now has the redirect URL. Yjs corrects it back, ProseMirror re-renders with the original, our controller rewrites it again —
We've introduced a `ProseMirrorExternalLinksController` subclass that mitigates categories 1 and 2 (skips `aria-describedby`, adds idempotency guards for `target`/`rel`). Category 3 (href rewrite with capture enabled) remains unresolved — enabling external link capture with BlockNote still freezes the browser.
## Current impact
- **`aria-describedby`**: Screen
- **External
## Proposed solution
Instead of manipulating the DOM from outside ProseMirror (Stimulus MutationObserver), handle these behaviors through ProseMirror's own plugin/decoration pipeline. ProseMirror natively manages decorations, so there's no mutation loop — ProseMirror owns the attributes.
A single ProseMirror plugin could handle all three concerns:
```typescript
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
const externalLinksPlugin = new Plugin({
props: {
decorations(state) {
const decorations: Decoration[] = [];
state.doc.descendants((node, pos) => {
node.marks
.filter(mark => mark.type.name === 'link')
.forEach(mark => {
const attrs: Record<string, string> = {};
// a11y: screen reader hint for blank-target links
if (mark.attrs.target === '_blank') {
attrs['aria-describedby'] = 'open-blank-target-link-description';
}
// External link capture: rewrite href for display only
// (decoration doesn't change the document model)
const href = mark.attrs.href;
if (isExternal(href) && captureEnabled) {
attrs['href'] = `/external_redirect?url=${encodeURIComponent(href)}`;
}
if (Object.keys(attrs).length > 0) {
decorations.push(
Decoration.inline(pos, pos + node.nodeSize, attrs)
);
}
});
});
return DecorationSet.create(state.doc, decorations);
},
},
});
```
Key advantage of the decoration approach for href rewriting: decorations are a **view-layer concern** — they modify what the user sees without changing the underlying document model. The original href stays in the Yjs document, so there's no conflict with collaboration, and the document content isn't corrupted with redirect URLs.
This plugin could be passed to BlockNote via `_tiptapOptions.extensions` (wrapping it in a TipTap extension). Note that `_tiptapOptions` is an internal/undocumented BlockNote API — it works and is tested, but isn't part of the public contract.
## Acceptance criteria
- External links
- External link capture (href rewrite to `/external_redirect`) works inside BlockNote without freezing
- The document model retains original hrefs (redirect is display-only via decorations)
- No browser freeze when pasting content with multiple links (existing regression test should keep passing)
- Body-level `ExternalLinksController` behavior unchanged
- Existing a11y tests in `spec/features/a11y/external_links_spec.rb` still pass
## Related files
- `frontend/src/stimulus/controllers/prosemirror-external-links.controller.ts` — current workaround subclass
- `frontend/src/stimulus/controllers/external-links.controller.ts` — base controller
- `frontend/src/react/components/OpBlockNoteEditor.tsx` — BlockNote editor setup, where plugin would be registered
- `frontend/src/elements/block-note-element.ts` — custom element, registers
- `modules/documents/spec/features/external_links_in_block_note_spec.rb` — feature tests including freeze regression
- `spec/features/a11y/external_links_spec.rb` — body-level a11y tests