Skip to content

Interactivity API: renderHTML function to insert and hydrate html strings - #81462

Open
nickchomey wants to merge 51 commits into
WordPress:trunkfrom
nickchomey:iapi/render-html
Open

nickchomey wants to merge 51 commits into
WordPress:trunkfrom
nickchomey:iapi/render-html

Conversation

@nickchomey

@nickchomey nickchomey commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

What?

Closes #81189

Summary

Adds a renderHTML() function to the Interactivity API, which allows for inserting html strings into the DOM and hydrating their iAPI directives in the Preact vdom.

Given an HTML string, it parses the markup, converts it to vDOM, and splices it into the island's existing Preact tree at the requested position, then renders. The diff creates only the new DOM nodes. Existing content keeps its component state and event listeners, and content removed by a later swap unmounts cleanly, with its listeners cleaned up.

Motivation

As described in #81189, fetching an HTML fragment from the server and injecting the response into the page is one of the oldest dynamic-content patterns on the web. It drives "load more" buttons, infinite scroll, form submissions, prepending a new item to a list, and re-rendering a region in place. Unfortunately, the pattern is not possible with the Interactivity API's islands, due to the underlying Preact VDom not becoming aware of it. As such, injected HTML is inert: its directives, context, etc... do nothing. The alternatives are either to do full-region swaps with the router - something that is very inefficient in both the server and client - or just writing javascript outside of the iAPI.

renderHTML() makes the classic pattern work with full Interactivity API support: the injected markup is hydrated, its directives run, and the content around it is left untouched.

import { renderHTML } from '@wordpress/interactivity';

const res = await fetch( '/wp-json/my-plugin/v1/posts' );
renderHTML( '#feed', await res.text() ); // append (default)

API

renderHTML( container: Element | string, html: string, options? );
  • container: an element or a CSS selector for the html to be inserted relative to
  • options.mode: the insertion mode. Valid modes are append (default), prepend, before, after, inner (replaces the container's children), or replace (replaces the container itself)
  • The container must either be inside a [data-wp-interactive] island, or carry its own data-wp-interactive attribute. An island in the HTML itself is then hydrated via the first-render path. Plain HTML with no island warns and does nothing.
  • Router regions are supported. See How it works.

How it works

The implementation lives in packages/interactivity/src/render.ts (spliceIntoTree(), rebuildPathNode(), applyKeyFallback(), writeRegionSignal()).

The HTML is parsed in a detached <template> and converted to vDOM with toVdom( node, namespace ), inheriting the nearest island's namespace so directives on the new content resolve the right store.

Each rendered element is mapped to its vnode by a chained options.diffed hook (elementToVnode). renderHTML finds the container in O(1), then walks _parent pointers to the island root, O(depth), independent of island size. A stale map entry, left when an element is removed by a splice and re-inserted, is rejected by the final root-equality check.

spliceIntoTree inserts the new vnodes into the container's rendered children at the position implied by mode. To apply the change, the tree is rebuilt from the root down to the insertion point: the vnodes on that path are recreated (rebuildPathNode), element vnodes with the new children array and Directives wrappers with the rebuilt element. Rebuilt path nodes keep their key, so a splice into a keyed item's subtree preserves the item's identity - its element, state, and event listeners survive, and only the new content is added. Vnodes off the path keep their exact object, so Preact's same-vnode-object bailout skips diffing them entirely. That keeps the cost at O(depth + siblings) rather than O(island).

render() diffs the rebuilt tree. New vnodes become new DOM nodes, matched vnodes keep their component instances, and removed vnodes unmount, running their cleanups.

Each inserted top-level item gets a Preact key: an explicit data-wp-key is the first priority, falling back to the element's id, then, for insertion modes (prepend/before/after), an auto-generated key is added. Without keys, Preact pairs children by index. A prepend puts the new item at index 0, so Preact diffs the new item against the element that used to be first - the new content would replace the old element's content, and every other element would get its neighbor's content. The new item's data-wp-init would never run, and each existing item's would run again. With keys, Preact pairs by key instead: the new item matches no existing element, mounts fresh, and each existing item re-pairs with its own element. Keys are added only to the vnodes renderHTML inserts; existing items are never given new keys, so their matching semantics are unchanged. Router navigation matches by key too: an item whose key appears on the new page is reused, with element and state kept, while keyed items with no match mount fresh.

When using renderHTML() with repeated content, it is prudent to add data-wp-key to both the content you inject and the server-rendered content it joins. The id fallback and auto-generated keys are conveniences - they cover common cases, but explicit keys are the reliable option.

A router region renders its content from a signal. renderHTML splices into the tree, but the region re-renders from the signal on the next island render, so a splice that touches a region also stores the new content in the signal. Without that, the region's next render would read the old signal value and the inserted content would disappear. Navigating to another page replaces the signal's content with the new page's content, which removes anything renderHTML had inserted.

Some cases warn and leave the tree untouched:

  • no island around the container and none in the HTML
  • data-wp-ignore subtrees
  • data-wp-each-child output
  • <template> targets
  • before/after at the island root; and replace at the island root when the new content carries no data-wp-interactive

Testing

A lot of unit and e2e tests were implemented in order to explore the limits of the approach in relation to the other capabilities of the iAPI, so as to be as seamless as possible. This process revealed many bugs that were able to largely be worked around.

Run the new unit tests:

npm run test:unit -- packages/interactivity/src/test/render-tree-first.ts
npm run test:unit -- packages/interactivity/src/test/outside-island-guards.ts
npm run test:unit -- packages/interactivity/src/test/router-region-splice.ts
npm run test:unit -- packages/interactivity-router/src/test/router-fragments.ts

Run the new e2e specs:

npm run test:e2e -- test/e2e/specs/interactivity/render-html.spec.ts
npm run test:e2e -- test/e2e/specs/interactivity/activity-feed.spec.ts

Known limitations

  • With no data-wp-key or id, inner/replace match content by position: the existing element is reused and the new content's data-wp-init never fires. Content with a key that matches nothing in the current tree mounts fresh instead, so data-wp-init runs and state resets. That includes items previously inserted with only an auto-generated key, since the auto key is created fresh on every parse and can never match an earlier parse's item.

Alternatives Considered

  • A previous PR, Interactivity API: Add renderElement() and renderHTML() to hydrate HTML inserted into the live DOM #81190, attempted to hydrated HTML that had already been injected into the DOM. I had high hopes for this, as it would perhaps even allow for things like using a MutationObserver to automatically hydrate HTML added via plugins' arbitrary javascript. But after an ever-growing set of hacks were implemented throughout the iAPI, It was concluded this approach was simple fundamentally incompatible with preact. The only possible approach is something like what was done in this PR.

Use of AI Tools

Deepseek V4 Flash via VSCode Copilot Chat as a pair programmer.

Full disclosure - I have not closely scrutinized every test, comment etc... My goal with this speculative/unsanctioned work was just to get it working and covering as many edge cases as I could, such that I could at least show that it is a viable path. Even with this approach, it still took me multiple entire days of work to get to the current state.

If/when you support the direction, I'll be more than happy to give it all a thorough personal review. I do not ask/expect anyone else to be doing that in their first pass review.

Likewise, things like names of files, functions, tests etc should not need scrutiny at this point. They can be modified later, as-needed.

@github-actions

github-actions Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Interactivity, [Package] Interactivity Router.

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@github-actions

Copy link
Copy Markdown

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: .

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@nickchomey

Copy link
Copy Markdown
Contributor Author

failed tests/linting seems to be spurious - probably just need to be re-run

…drop pre-existing/expected limitation items; note same-key navigation reuse

test: strengthen path-rebuild key-preservation tests (nested children, siblings, init counts)

e2e: same-key re-navigation reuses keyed items (repeat filter visit)
…eck (non-null containers, VNode cast, drop stale ts-expect-error)
@github-actions github-actions Bot added the [Package] E2E Tests /packages/e2e-tests label Oct 16, 2026
@github-actions

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

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: nickchomey <nickchomey@git.wordpress.org>

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

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

This branch has not been deployed

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

Labels

[Package] E2E Tests /packages/e2e-tests [Package] Interactivity Router /packages/interactivity-router [Package] Interactivity /packages/interactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Interactivity API: Add a public API to render/hydrate HTML inserted into the live DOM

1 participant