Skip to content

Interactivity API: Render data-wp-html on the server as the client hydrates it. - #15737

Open
SantosGuillamot wants to merge 10 commits into
WordPress:trunkfrom
SantosGuillamot:fix/data-wp-html-server-client-parity
Open

SantosGuillamot wants to merge 10 commits into
WordPress:trunkfrom
SantosGuillamot:fix/data-wp-html-server-client-parity

Conversation

@SantosGuillamot

@SantosGuillamot SantosGuillamot commented Nov 6, 2026 •

Copy link
Copy Markdown

64386 added server-side processing of the data-wp-html directive before the client's behavior was settled in Gutenberg. In several cases the server and the client disagree, so hydration changes what the server rendered: the fallback disappears, the trusted HTML disappears, or content the server processed is rendered again by the client.

This makes the server follow the client's rules, so the HTML a data-wp-html element is served with is what the client keeps after hydration.

Behavior changes against trunk

  • Values. Only a token from wp_interactivity_as_dangerous_html() renders HTML, as before.
    • null and references that resolve to nothing empty the element without a notice: a missing path, a derived-state closure that throws, a __proto__, constructor or prototype path segment or namespace, an empty or missing attribute value.
    • Any other value that is not a token empties the element with a notice. Previously the fallback was kept in all these cases.
  • References are read raw. data-wp-html='"state.html"' no longer renders the token: the value is never JSON-decoded. A ! applies after the reserved-name check, and !!state.x resolves to nothing, as in the client.
  • Conflicts. data-wp-html is ignored, with a notice, when the element also carries data-wp-text, data-wp-each or data-wp-router-region in any form (with a suffix or a unique ID, and data-wp-each on any element).
  • Inert content. Directives inside a data-wp-html element are never processed, and derived-state closures referenced only there are not listed in the client data.
  • Namespaces. An unprefixed reference resolves in the namespace of the nearest data-wp-interactive only. When that attribute names no namespace (no value, an empty value, invalid JSON, a reserved word), the element is emptied as the client empties it. Core produces this markup: the fit-text typography support adds a valueless data-wp-interactive. A suffixed data-wp-interactive--x or data-wp-interactive---x does not start an island, as in the client.
  • Raw-text and void elements. A textarea, title, script or other raw-text element is emptied for a value that is not a token, and keeps its text, with a notice, for a token. Void elements are left as written.
  • Any number of elements. The content of each data-wp-html element is replaced in one forward pass instead of seeking back per element, so a call with more than 1,000 data-wp-html elements is processed instead of being returned unchanged.
  • Nested calls. A wp_interactivity_process_directives() call made while another is running, for example from a derived-state closure, saves and restores the state of the outer call. Previously the outer call either ended in a fatal error or printed a raw <!--wp-interactivity-dangerous-html:N--> placeholder.

Not in scope

  • Directives inside svg and math elements are still not processed, as for every other directive.
  • The handling of a self-closed <svg /> or <math />.
  • How other directives treat reserved names.

Testing

npm run test:php -- --group interactivity-api

Trac ticket: https://core.trac.wordpress.org/ticket/66246

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Fable and Opus
Used for: Code, tests, and comments in this PR were autonomously generated by AI and reviewed by @SantosGuillamot before the PR was opened.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

🤖 Generated with Claude Code

…ctives processor.

`WP_Interactivity_API_Directives_Processor::skip_to_balanced_tag_closer()` moves to the matching closer tag of the current opener tag and optionally replaces the content between them. It neither applies the pending updates nor seeks back to the opener tag, so a processor can replace the content of any number of elements.

See #66246.
…hydrates it.

The server now follows the client's rules for `data-wp-html`, so hydration keeps what the server rendered:

- A host whose reference resolves to `null` or to nothing is emptied without a notice. That covers missing paths, throwing derived-state closures, references with a `__proto__`, `constructor` or `prototype` path segment or namespace, empty values, and values that name no property. The reference is read raw and never decoded as JSON.
- A host whose reference resolves to any other value that is not a token is emptied with a notice. A `!` applies after the reserved-name check, as in the client.
- The content of an element that carries a `data-wp-html` entry without a suffix is never processed, so the directives in it do not run and the derived-state closures referenced only there are not listed. An element with only unique-ID entries keeps its content as written.
- `data-wp-html` is ignored, with a notice, when the element also carries `data-wp-text`, `data-wp-each` or `data-wp-router-region` in any form, on any element. Directives the client drops for a reserved name part or namespace do not count, and a dropped `data-wp-html` leaves the element as if it had none.
- A `script`, `style`, `textarea`, `title` or other raw-text host is emptied for a value that is not a token, and keeps its text with a notice for a token.

The content of each host is consumed in one forward pass, without seeking back to its opener tag, so a call renders every host however many it holds, and processing time grows with the size of the page alone. The rewinding `WP_Interactivity_API_Directives_Processor::set_raw_content_between_balanced_tags()` is removed.

See #66246.
… one pass.

The `data-wp-html` processor now parses an element's directive attributes once, for its own entries and for the directives that conflict with it, instead of once per directive prefix.

See #66246.
…!` to nothing.

The client removes one leading `!` and reads the rest of the path as given, so `!!state.x` names a property `!state` that does not exist and `state.x` is never read. The server now does the same instead of negating twice, so no derived-state closure on that path runs or is listed.

See #66246.
…t replacement.

`WP_Interactivity_API_Directives_Processor::skip_to_balanced_tag_closer()` always replaces the content now. An element whose `data-wp-html` entries all have a unique ID is skipped with `next_balanced_tag_closer_tag()`, which keeps its content.

See #66246.
…t island names.

The client takes an unprefixed reference's namespace from the nearest `data-wp-interactive` alone. When that directive has no value, an empty one, a JSON object without a string `namespace`, or a reserved word, no namespace is in effect, even if an enclosing directive names one. Core's fit-text typography support adds such a valueless `data-wp-interactive` to blocks that can sit inside a region.

`data_wp_interactive_processor()` now also records the namespace each directive names by that reading, and `data-wp-html` resolves in it, so a host there is emptied as the client empties it. Every other directive keeps resolving in the inherited namespace.

See #66246.
…is processing.

A call made while another call is processing, as from a derived-state closure or a state value that renders markup, used to reset the shared stacks and placeholders and leave them empty when it returned. The other call then ended in a fatal `TypeError`, or shipped a placeholder comment in place of its `data-wp-html` content.

Each call now saves the processing state it finds (the namespace and context stacks, the current element and the `data-wp-html` placeholders) and restores it when it returns, however it ends. Its placeholder marker is longer than that of any call it runs inside, so the placeholders of the two calls never coincide.

See #66246.
…der a suffixed `data-wp-interactive`.

Only the attribute named exactly `data-wp-interactive` makes an island in the client. An element that carries only `data-wp-interactive--<suffix>` or `data-wp-interactive---<id>` is no island, so a `data-wp-html` host inside it resolves its reference in the enclosing namespace. The server recorded such an element as an island that names no namespace, and emptied the host.

See #66246.
@github-actions

github-actions Bot commented Nov 6, 2026

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.

Core Committers: Use this line as a base for the props when committing in SVN:

Props santosguillamot.

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


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant