Skip to content

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

Description

@nickchomey

Summary

Add two small public functions to @wordpress/interactivity:

  • renderElement( element | element[] ) — makes an element (or contiguous siblings) already inserted into the live DOM fully interactive, by processing all Interactivity API directives on it (event handlers, bindings, nested islands).
  • renderHTML( container: Element | string, html, { position } ) — a convenience wrapper that parses an HTML string (eg from a fetch request), inserts the resulting elements into the DOM at the given position (append / prepend / before / after / inner / outer), and calls renderElement() on them. The container can be passed as an element or as a CSS selector.

The HTML commonly comes from a server — e.g. a fragment fetched from a REST endpoint — but can come from anywhere.

The problem

The Interactivity API hydrates interactive islands that are present at first paint. hydrateRegions() runs once on DOMContentLoaded over all [data-wp-interactive] nodes and is intentionally one-shot. Any markup inserted into the DOM afterwards — the ubiquitous "fetch a server-rendered fragment and append it" pattern — is never hydrated, and thus their iAPI directives remain inert and new context does not update.

  • data-wp-on--* handlers never bind (clicks silently do nothing).
  • data-wp-text, data-wp-bind, data-wp-class, data-wp-style, data-wp-each, … never run.
  • Nested islands inside the fragment are equally dead.

This is a common, legitimate pattern with no supported solution:

  • Optimistic feed/post creation (insert the server-rendered card for a just-created post without reloading).
  • Infinite scroll / load-more that appends server-rendered cards.
  • Live-updating lists (messages, notifications, comments).
  • Modal/drawer content fetched on demand.

There is concrete real-world evidence this is a blocker. In buddynext/buddynext#112, a plugin that relies heavily on the Interactivity API had to disable its server-render-then-inject path (prependFeedCard) because injected markup carrying data-wp-* directives was never hydrated — every button on an injected post card was dead until a full page reload. The maintainer's own summary is: "Injected markup is never hydrated … WordPress 7.0 exports no public hydrate or init from @wordpress/interactivity; core's own router reaches for privateApis, which a plugin has no business depending on." The workaround forced a full page reload on every post submit — a fetch followed by a full reload — and hundreds of lines of hand-written createElement DOM building with per-element listeners, which is exactly what this proposal would let plugin authors avoid.

The client-side navigation compatibility guide (docs/reference-guides/interactivity-api/core-concepts/client-side-navigation-compatibility.md) warns against "mutat[ing] the DOM outside the Interactivity API" and points at data-wp-watch and the router's attachTo as the sanctioned alternatives — but neither can make an arbitrary inserted fragment interactive:

  • data-wp-watch + ref.innerHTML = … assigns raw HTML; directives inside it are never processed, so the result is dead markup.
  • attachTo regions are created only when a router navigation delivers them as part of a fetched page. It cannot render a fragment the client already holds (e.g. a REST response body).

The Interactivity Router also isn't a general answer: it hydrates only swapped data-wp-router-region elements during a client-side navigation, and client-side navigation is off by default. It requires fetching the entire destination page — which is a real cost in itself: see buddynext/buddynext#141, where "Load More" was moved onto the router and now has to fetch the entire page (with the full existing feed) just to append a few new posts. This proposal is complementary: it would let load-more-style features append a small server-rendered fragment directly, without the router's full-page fetch. (The router's full-page-fetch behaviour is a separate issue; this proposal is not attempting to fix it.)

Proposal

Add to @wordpress/interactivity:

renderElement( element: Element | Element[] ): void
renderHTML( container: Element | string, html: string, options?: { position?: 'append' | 'prepend' | 'before' | 'after' | 'inner' | 'outer' } ): void

renderElement() contract:

  1. Must be attached first. The element(s) must already be in the DOM (the root-fragment mechanism requires a parent element). The function throws a clear error otherwise.
  2. Whole subtree, one pass. Directives on the element and its entire subtree are processed in a single render, including nested data-wp-interactive islands (data-wp-ignore subtrees stay inert).
  3. Namespace and context inheritance. A fragment without its own data-wp-interactive attribute is treated as part of the enclosing island: its directives resolve against the nearest ancestor island's namespace, and it inherits the live context at its insertion point. Context writes behave exactly as they would if the fragment had been in the original document: a fragment without its own data-wp-context writes through to the enclosing island's context (the island's elements react); a fragment with its own data-wp-context scopes its writes to itself (the island is unaffected). A fragment that carries its own data-wp-interactive behaves as a self-contained island.
  4. Idempotent. Calling again with the same element updates it in place — the runtime diffs against the previous render (the root fragment is cached by node identity), so no duplicate listeners, no remount, no flicker.
  5. Surgical. Only the passed element(s) are affected; siblings and the surrounding router region are untouched.
  6. Router-compatible. An element inserted inside a router region is cleanly removed/replaced on the next navigation. An element carrying data-wp-router-region registers as a swappable region.
  7. Graceful no-op. If the fragment has no enclosing island and no own data-wp-interactive, nothing is hydrated: a warning is logged and the DOM is left untouched.

Multiple elements (contiguous siblings under the same parent — e.g. a list of load-more cards) are supported in a single call; otherwise call once per element.

renderHTML() contract:

  • Parses the HTML string via a <template> (no image-load side effects), inserts the resulting element(s) at the given position, then delegates to renderElement().
  • Because it parses fresh nodes on every call, repeated calls mount fresh content rather than diffing against the previous call's nodes — unlike renderElement() re-called with the same element, which diffs in place. position: 'inner' makes renderHTML( ref, html, { position: 'inner' } ) a drop-in replacement for ref.innerHTML = html that actually hydrates the markup.
  • container accepts an element or a CSS selector (resolved via document.querySelector; a selector matching nothing throws).

Alternatives considered

  • Document the privateApis recipe — not a supported, stable surface for plugin authors; the router's internal use is not a substitute for a public API.
  • Make hydrateRegions() re-callable — it is one-shot by design (initialVdomPromise resolves once); re-running would re-walk already-owned islands and conflict with preact's per-region ownership.
  • Router-only (attachTo) — only covers content delivered inside a fetched page during navigation; cannot render a fragment the client already holds, and requires a full-page fetch (see buddynext/buddynext#141).
  • data-wp-watch + innerHTML — the resulting markup is dead (directives never processed); not a substitute.
  • Router partial responses — complementary, not a substitute: the router replaces regions from fragments; it cannot append region-less cards into a list (see the related router-partial issue). renderElement covers insertion/append.
  • data-wp-each over the list (suggested in the BuddyNext thread) — a legitimate alternative for lists, but it puts templating/processing in the browser rather than injecting SSR HTML, and it doesn't help for one-off fragments (modals, drawers, single cards). This proposal complements it: data-wp-each for client-side lists, renderElement for SSR-insertion.

Related work

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions