Skip to content

Interactivity API: Add server-side processing for the data-wp-html directive. - #14301

Closed
DAreRodz wants to merge 16 commits into
WordPress:trunkfrom
DAreRodz:data-wp-html-server-processing
Closed

DAreRodz wants to merge 16 commits into
WordPress:trunkfrom
DAreRodz:data-wp-html-server-processing

Conversation

@DAreRodz

@DAreRodz DAreRodz commented Oct 15, 2026 •

Copy link
Copy Markdown

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

Adds server-side processing for the data-wp-html directive, whose client implementation was merged in WordPress/gutenberg#83092, so that trusted HTML produced in PHP is present in the server-rendered page, including inside data-wp-each items (WordPress/gutenberg#70531).

What it adds

  • wp_interactivity_as_dangerous_html( string $html ): returns a WP_Interactivity_Dangerous_HTML token that marks the HTML as trusted. The token keeps the HTML in a private property, so encoding it as JSON never includes it.
  • A data-wp-html directive processor. When the reference resolves to a token, the element's content is replaced with the token's HTML, as given. The element keeps its tag and attributes, and its other directives are processed as usual.

Behavior

  • Only tokens render. Strings (sanitized or not), arrays, other objects, values from data-wp-context, and anything produced by json_decode() never render.
  • Unserializing a token does not throw. Its __wakeup() clears the HTML and raises a _doing_it_wrong() notice, so a page that cached a token keeps working, and the unserialized token never renders.
  • The HTML is neither sanitized nor validated, and directives inside it are not processed on the server.
  • Neither the token nor its HTML is written into the Interactivity data printed for the client or into attributes: a token prints as {}.
  • The path of a token reached through state is listed in derivedStateClosures, the same way a derived-state closure's path is, so the client keeps the server-rendered content until its store supplies a value. Tokens reached through context, or through the result of a derived-state closure, are not listed.
  • When the directive does not render, the element keeps its existing content. A _doing_it_wrong() notice is raised when the value is not a token, when the element cannot hold content (void elements and script, style, textarea, title, iframe, noembed, noframes, xmp), or when the element also carries data-wp-text or is a template with data-wp-each. null and unresolved references keep the content without a notice.
  • No other directive changes.

Implementation notes

The processor writes an inert placeholder comment as the element's content, and process_directives() swaps each placeholder for its HTML with a single strtr() pass once processing has finished. The trusted HTML is therefore never scanned, and the processing loop and data-wp-each are unchanged. The placeholder marker is chosen so that it never occurs in the input. A new set_raw_content_between_balanced_tags() helper writes the placeholder without escaping.

Known limitations, left out of scope:

  • A derived-state closure that calls wp_interactivity_process_directives() starts a new call, so the outer call's placeholders are not substituted. Re-entrant processing is already broken in trunk, because the inner call resets the namespace and context stacks.
  • data-wp-style referencing a token fails as it does for any object in trunk (a warning on PHP 7.4, a TypeError on PHP 8 and later). The general fix, treating every non-scalar data-wp-style value as null, belongs in a separate ticket.

Testing

PHPUnit only; hydration in the browser is covered by the Gutenberg PR.

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

New test classes: Tests_WP_Interactivity_API_WP_Html and Tests_Interactivity_API_WpInteractivityDangerousHtml.

Use of AI Tools

AI assistance: Yes
Tool(s): OpenCode, running an autonomous multi-agent pipeline (Radical Pipelines) that wrote and reviewed each stage separately
Model(s): Claude Opus 5.5, Claude Sonnet 5.5, GPT-6.1 Sol
Used for: 100% of the code, tests, and comments in this PR were autonomously generated by AI and reviewed by @DAreRodz 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.

@github-actions

github-actions Bot commented Oct 15, 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. You can also manually ask me to refresh this list by adding the props-bot label.

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

Props darerodz.

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

@github-actions

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.

@DAreRodz
DAreRodz force-pushed the data-wp-html-server-processing branch from d6b7a10 to 086c519 Compare October 15, 2026 09:55
…or does not visit.

`data-wp-html` declines on these tags through `has_and_visits_its_closer_tag()`, so its tests rely on this one to cover the full list. It omitted `noembed`, `noframes`, `title` and `xmp`, and listed `iframe` twice.
Tests that reached the same code path with the same kind of value are merged, keeping every behaviour they checked:

- The HTML strings with directives and with stray closers become data sets of the byte-for-byte test, and the six-host test now asserts the exact output instead of counting the HTML.
- The children of a declining host move into the `null` and not-a-token tests, which replace the separate children test.
- The cannot-hold-content, `data-wp-text` and `template` with `data-wp-each` tests, the not-evaluated test, and the two message tests become one structural test. It resolves the reference through a derived-state closure and asserts that nothing is listed, so the value cannot matter, and checks that the notice names the tag or directive.
- The unserialized token's listing is asserted by the unserialization test.
- The bind-only listing test keeps only its listing assertion, and the authored literals keep a JSON object and a namespaced integer.

`test_calls_do_not_share_trusted_html` is removed, as it could not fail. Assertions that repeat an exact output comparison are removed. The forged placeholder test now also holds a lookalike of the extended marker. The context test is renamed to say it is the HTML string that declines. Helpers move before the tests, and `add_hooks()` is no longer called.
The class is private, and every behaviour its tests checked is what `data-wp-html` renders or declines, so they move into the directive tests and their file is removed:

- The whitespace, null byte and multibyte strings become data sets of the byte-for-byte test, which now checks them in the rendered output.
- The empty string and an array with an `html` key become values that are not tokens.
- The unserialization test also renders the token that was serialized, so serializing a token cannot clear its HTML.

The tests that only repeated directive tests are dropped: unserialization raising one notice, tokens created with `new` or `clone`, unserialization leaving other tokens alone, and JSON encoding, which the client data tests check.
On a page, directives are processed by `WP_Block::render()` once the root interactive block has rendered, not by a direct call. The new test renders two sibling root interactive blocks whose render callbacks set the same state property to different tokens, and checks that each renders its own HTML with no placeholder left.
- The render test is folded into the test that also checks `data-wp-bind` and the client data, which uses the same data sets and asserts the same output.
- The negated reference moves into the full-path test, on a path no `data-wp-html` host references, so that test also checks that a token referenced only by another directive is listed. The separate bind-only, listed-once and unreferenced-token tests go: the full-path test lists repeated paths once, and the context test holds tokens in state that no `state` reference reaches.
- The ignored-entries and default-entry tests become one page, where a host with only a suffix and a unique ID entry pointing at a token stays as it is.
- The HTML string from context and the per-item decline in `data-wp-each` go, as they take the same paths as the not-a-token and `data-wp-each` tests.
- The one-line `expect_html_notices()` and `notice_sources()` helpers are inlined.
@DAreRodz
DAreRodz requested a review from luisherranz October 15, 2026 15:05

@luisherranz luisherranz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks @DAreRodz.

@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 64386
GitHub commit: 3effce4

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

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.

2 participants