Skip to content

Rich text: resolve owned event listeners once instead of per subscriber - #80605

Open
ellatrix wants to merge 4 commits into
trunkfrom
perf/owned-listener-dispatch
Open

Rich text: resolve owned event listeners once instead of per subscriber#80605
ellatrix wants to merge 4 commits into
trunkfrom
perf/owned-listener-dispatch

Conversation

@ellatrix

@ellatrix ellatrix commented Jul 23, 2026

Copy link
Copy Markdown
Member

What?

Rewrites subscribeOwnedListener so it resolves which element owns an event once per event, instead of every subscriber checking whether it owns the event on every event.

Alternative to #80517 (same fix, simpler dispatch).

Why?

Typing performance regression from editableRoot (#79105). Each editable subscribes owned listeners bound to the document, so all of them run on every event and each calls ownsSelection. With ~8 keydown subscriptions per editable and an editable per block, a keystroke in a 1250-block post is ~10,000 ownsSelection calls.

Note: this is what actually moves the metric. #80549 (avoid the forced layout) and #80548 (reorder the checks) both flatlined in CI — the cost is the number of calls, not the per-call work.

How?

One delegated listener per document, event type and phase. Its callback finds the owning element and fires its callbacks:

  • These are editing and selection events, so the owner is the element that owns the selection (ownsSelection), and it contains the selection anchor.
  • So the callback walks up from the selection anchor, testing ownsSelection, instead of testing every subscriber. That is O(DOM depth), not O(blocks). When there is no selection, an editable can still be focused, so it starts from the focused element instead.

selectionchange and ensureSelectionSync move onto subscribeOwnedListener too, so they ride the same per-element dispatch and stay ordered ahead of the element's other owned listeners, keeping the record synced before they run. This is required: without it, a later block's sync runs after the shared dispatch and input acts on a stale record.

Testing Instructions

  1. Typing, splitting and merging, formatting, and cross-block navigation behave as before.
  2. e2e passing locally: writing-flow (208), splitting-merging (44), rich-text, editable-root-compat (6).
  3. Watch the type metric on codevitals for the drop.

Use of AI Tools

Written with the help of Claude Code.

Rewrite subscribeOwnedListener to share one delegated listener per document,
event type and phase, with a dispatch that finds the owning elements by walking
up from the event target, the selection anchor and the focused element, and
firing their callbacks. It applies the same contains-or-ownsSelection check as
before, only on the nodes that could pass it, instead of every subscriber
running it on every event (O(blocks) per keystroke).

Move selectionchange and ensureSelectionSync onto subscribeOwnedListener too, so
they ride the same per-element dispatch and stay ordered ahead of the other
owned listeners for the element, keeping the record synced before they run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
@github-actions github-actions Bot added the [Package] Rich text /packages/rich-text label Jul 23, 2026
@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: ellatrix <ellatrix@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

Size Change: +68 B (0%)

Total Size: 7.75 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 428 kB -64 B (-0.01%)
build/scripts/components/index.min.js 273 kB -28 B (-0.01%)
build/scripts/rich-text/index.min.js 15 kB +160 B (+1.08%)

compressed-size-action

ellatrix and others added 3 commits July 22, 2026 19:36
Every subscribeOwnedListener consumer is an editing or selection event, so the
owner is the element that owns the selection, and the event target is always
the caret. That makes the target chain and the contains-target check redundant:
walk up only from the selection anchor (the element that contains the selection)
and the focused element (the case where an editable is focused but the selection
is elsewhere), testing ownsSelection alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
The owner always contains the selection anchor, so one walk up from the anchor
finds it; fall back to the focused element only when there is no selection. This
drops the second chain and the dedup set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
It had a single caller and closed over elements and ownerDocument already, so
folding it in drops the indirection and the parameters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XrG35JQKJBX1iNof5wtfjN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Rich text /packages/rich-text

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant