Skip to content

Interactivity: prototype data-wp-preserve directive - #80303

Closed
fabiankaegy wants to merge 1 commit into
trunkfrom
try/interactivity-data-wp-preserve
Closed

fabiankaegy wants to merge 1 commit into
trunkfrom
try/interactivity-data-wp-preserve

Conversation

@fabiankaegy

Copy link
Copy Markdown
Member

What?

This is a prototype PR, meant to make the discussion in #80298 concrete rather than to be merged as is. It adds an opt-in data-wp-preserve directive to the Interactivity API. When an element in the newly fetched page has data-wp-preserve and an id, and an element with the same directive and id already exists in the current live DOM, the router keeps that live element (and its whole subtree) as is during client-side navigation, instead of diffing it or replacing it with the server-rendered version.

Part of #80298

Why?

data-wp-ignore was deprecated with no replacement (#70881), and nobody following up on that issue got a working alternative. The underlying problem is bigger than data-wp-ignore itself: third-party scripts that aren't built on the Interactivity API (consent managers like OneTrust, chat widgets, various embeds) inject DOM into the page at runtime. The router's vDOM diffing doesn't know about that DOM, so it either removes it or clobbers it on the next navigation. The docs already warn about this with no real solution offered.

Other client-side routers already solve this exact problem: Turbo has data-turbo-permanent, htmx has hx-preserve. Both are for the same category of third-party DOM the Interactivity API router struggles with.

How?

data-wp-preserve goes on a wrapper element that the site owner controls (a theme or block renders it), since a third-party script will never add a WordPress-specific attribute to the markup it injects itself.

The implementation deliberately avoids the bugs that got data-wp-ignore deprecated (context inheritance breakage, incorrect behavior across navigation). Instead of freezing the initial server HTML string in a useMemo, it keeps a module-level registry mapping each preserved element's id to its actual live DOM node. On every render:

  • The server-rendered HTML is only ever applied once, on first mount (mirroring how data-wp-ignore avoids touching the DOM when nothing changed).
  • A ref callback (deliberately a fresh function every render, so Preact invokes it on every render rather than only on mount/unmount) checks whether a different DOM node was already preserved for this id. If so, it means this component instance couldn't be reused across the navigation (e.g. the surrounding markup changed enough that Preact recreated it), so it moves the previously preserved live children over onto the freshly created node, overriding whatever server HTML was just applied to it.

This means the actual DOM nodes (and anything injected into them after the page loaded) survive navigation, rather than a stringified snapshot of innerHTML that would lose any live state inside the preserved subtree (video playback position, focus, third-party JS instances, etc).

An element without an id can't be matched across navigations, so the directive is ignored with a SCRIPT_DEBUG warning in that case, falling back to normal processing.

Known limitations (documented in the docs update)

  • Nested router regions aren't supported inside a preserved element. Since the subtree is never diffed or walked, a data-wp-router-region placed inside a data-wp-preserve element won't update during navigation. This is called out in the docs as a limitation rather than solved here.
  • Other directives on the same element as data-wp-preserve are not processed, the same restriction data-wp-ignore has today. Put them on a parent element instead.
  • This prototype focuses on the common case of region-based client-side navigation. It should also work with the experimental full-page navigation mode since the implementation isn't router-region-specific, but that hasn't been explicitly tested here.
  • The registry holding live DOM references never actively evicts an id if the wrapper is later removed from the page entirely. This should be bounded in practice (one entry per distinct id used across a page's lifetime), but it's worth calling out.

Testing Instructions

  1. Check out this branch and start wp-env.
  2. Activate the "Gutenberg Test Interactive Blocks" plugin.
  3. Create a post with the test/directive-preserve block, which renders a data-wp-preserve wrapper inside a router region.
  4. Open the browser console and simulate a third-party script by running something like:
    document.getElementById( 'preserved-widget' ).appendChild(
    	Object.assign( document.createElement( 'div' ), {
    		textContent: 'injected content',
    	} )
    );
  5. Click the "Navigate" button on the page, which triggers a client-side navigation to a version of the page with different server-rendered content for that same wrapper id.
  6. Confirm the injected div and the original content are both still there, untouched by the navigation.

Alternatively, run the new e2e spec: npm run test:e2e -- test/e2e/specs/interactivity/directive-preserve.spec.ts (requires wp-env-test; not run in this environment, CI will run it).

Testing Instructions for Keyboard

Not applicable, this PR has no user-facing UI.

Use of AI Tools

This PR was implemented by Claude Code (Claude Fable 5), based on the proposal filed in #80298 by @fabiankaegy. The implementation approach (module-level live-DOM registry instead of freezing an HTML string, informed by the known bugs in data-wp-ignore) was researched and written by the AI agent, and reviewed before opening this PR.

🤖 Generated with Claude Code

Adds an opt-in `data-wp-preserve` directive that keeps a live element (matched
by its `id`) intact across client-side navigations instead of letting the
router diff or replace it. It's meant for wrapper elements a site owner
controls around DOM that third-party scripts inject at runtime (consent
managers, chat widgets, embeds), which the router's vDOM diffing can't see
and otherwise clobbers or removes on navigation.

This is a discussion prototype for #80298, the proposed successor to the
deprecated data-wp-ignore directive. Unlike data-wp-ignore, which freezes the
initial server HTML string and can lose injected DOM after navigating away
and back, this keeps a registry of the actual live DOM nodes so the real
subtree (including anything injected into it) survives even when the
component instance backing it isn't reused across a navigation.

Part of #80298

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the [Package] Interactivity /packages/interactivity label Jul 15, 2026
@github-actions

Copy link
Copy Markdown

Size Change: +99 B (0%)

Total Size: 7.72 MB

📦 View Changed
Filename Size Change
build/modules/interactivity/index.min.js 15.4 kB +99 B (+0.65%)

compressed-size-action

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

Labels

[Package] Interactivity /packages/interactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant