Repository navigation
Interactivity API: renderHTML function to insert and hydrate html strings - #81462
nickchomey wants to merge 51 commits into
Conversation
|
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.
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. |
|
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.
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. |
|
failed tests/linting seems to be spurious - probably just need to be re-run |
…drop public export
… (datastar pattern)
…enderHTML + key semantics
…pi- prefix, no seenKeys warning)
…ate-post prepend)
…tom; delete probe
…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)
3d24fdf to
d7a35f8
Compare
🤖 PR meta 🤖🎉 PropsIf you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. 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 |
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.API
container: an element or a CSS selector for the html to be inserted relative tooptions.mode: the insertion mode. Valid modes areappend(default),prepend,before,after,inner(replaces the container's children), orreplace(replaces the container itself)[data-wp-interactive]island, or carry its owndata-wp-interactiveattribute. An island in the HTML itself is then hydrated via the first-render path. Plain HTML with no island warns and does nothing.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 withtoVdom( 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.diffedhook (elementToVnode).renderHTMLfinds the container in O(1), then walks_parentpointers 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.spliceIntoTreeinserts the new vnodes into the container's rendered children at the position implied bymode. 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 andDirectiveswrappers 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-keyis the first priority, falling back to the element'sid, 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'sdata-wp-initwould 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 vnodesrenderHTMLinserts; 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 adddata-wp-keyto both the content you inject and the server-rendered content it joins. Theidfallback 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.
renderHTMLsplices 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 anythingrenderHTMLhad inserted.Some cases warn and leave the tree untouched:
data-wp-ignoresubtreesdata-wp-each-childoutput<template>targetsbefore/afterat the island root; andreplaceat the island root when the new content carries nodata-wp-interactiveTesting
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:
Run the new e2e specs:
Known limitations
data-wp-keyorid,inner/replacematch content by position: the existing element is reused and the new content'sdata-wp-initnever fires. Content with a key that matches nothing in the current tree mounts fresh instead, sodata-wp-initruns 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
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.