diff --git a/docs/reference-guides/interactivity-api/directives-and-store.md b/docs/reference-guides/interactivity-api/directives-and-store.md
index c97bf3753bcd27..82237c644d7f3e 100644
--- a/docs/reference-guides/interactivity-api/directives-and-store.md
+++ b/docs/reference-guides/interactivity-api/directives-and-store.md
@@ -400,7 +400,6 @@ On the same element as `data-wp-text`, `data-wp-each` or `data-wp-router-region`
- **Trusted Types.**
- The package creates no policy. Pass `asDangerousHTML( policy.createHTML( markup ) )`.
- A rejected value empties the element, and a warning is shown in development mode.
- - Known limitation: elements created after hydration, such as `data-wp-each` items, whose value is pending or `{ "wpServerHtml": true }` must have an empty fallback on sites enforcing Trusted Types.
- **"Inert" means inert to the Interactivity API only.** The inserted content is still live browser HTML. Content that isn't safe to render as HTML isn't made safe by going through `wp-html`.
### `wp-on`
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-html/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-html/render.php
index 1e204f8d21eaaf..da62a87780d072 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-html/render.php
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-html/render.php
@@ -9,8 +9,29 @@
'directive-html',
array(
'serverHtml' => array( 'wpServerHtml' => true ),
+ 'lazy' => static function () {
+ return null;
+ },
)
);
+
+add_filter(
+ 'script_module_data_@wordpress/interactivity',
+ /**
+ * Marks client-only values as pending until their fixture actions register.
+ *
+ * @param array|null $data Serialized Interactivity API server data.
+ * @return array Server data with the pending fixture value declarations.
+ */
+ static function ( $data ) {
+ $data = $data ?? array();
+ $data['derivedStateClosures']['directive-html'] = array_merge(
+ $data['derivedStateClosures']['directive-html'] ?? array(),
+ array( 'state.lazy' )
+ );
+ return $data;
+ }
+);
?>
@@ -28,6 +49,20 @@
+
Add fallback
+
Add two fallbacks
+
Add lazy item
+
Register lazy value
+
Add script items
+
+
+
Set Rejected HTML
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-html/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-html/view.js
index e243617eefaf7a..7ad1c376637bfd 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-html/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-html/view.js
@@ -4,6 +4,11 @@ const policy = window.trustedTypes?.createPolicy( 'directive-html', {
createHTML: ( html ) => html,
} );
+// Matches the server's token representation for fallback creation cases.
+const serverHTML = { wpServerHtml: true };
+// Supplies unique item IDs for the created list items.
+let itemId = 0;
+
const { state } = store( 'directive-html', {
state: {
html: null,
@@ -16,8 +21,52 @@ const { state } = store( 'directive-html', {
empty: null,
list: [ 'a', 'b' ],
hydration: 'hydrated',
+ /** Items whose individual values are server HTML representations. */
+ fallbackItems: [],
+ /** Items bound to the client getter registered by the fixture. */
+ lazyItems: [],
+ /** Items whose fallback contains an inert script fixture. */
+ scriptItems: [],
},
actions: {
+ /** Adds one item whose pending representation should show its fallback. */
+ addFallback() {
+ state.fallbackItems.push( {
+ id: ++itemId,
+ html: serverHTML,
+ } );
+ },
+ /** Adds two fallback items in the same reactive update. */
+ addTwoFallbacks() {
+ state.fallbackItems.push(
+ { id: ++itemId, html: serverHTML },
+ { id: ++itemId, html: serverHTML }
+ );
+ },
+ /** Adds an item whose value remains pending until its getter is registered. */
+ addLazyItem() {
+ state.lazyItems.push( { id: ++itemId } );
+ },
+ /** Registers the late trusted HTML getter for the pending item. */
+ registerLazy() {
+ store( 'directive-html', {
+ state: {
+ /** Resolves the pending value to trusted markup. */
+ get lazy() {
+ return asDangerousHTML(
+ policy.createHTML( 'Late ' )
+ );
+ },
+ },
+ } );
+ },
+ /** Adds two items whose fallback includes a script that must stay inert. */
+ addScriptItems() {
+ state.scriptItems.push(
+ { id: ++itemId, html: serverHTML },
+ { id: ++itemId, html: serverHTML }
+ );
+ },
setHtml() {
state.html = asDangerousHTML(
'Rendered HTML '
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-html/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-html/render.php
index b22b233dbb3f8c..1aaf880953adbf 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/router-html/render.php
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-html/render.php
@@ -55,6 +55,9 @@ function ( $data ) {
content from
+
+
+
not hydrated
Resolve HTML
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index cd711c117df3bc..18c7ab64295366 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -12,6 +12,7 @@
### Bug Fixes
+- `data-wp-html` elements that the client creates instead of hydrating, such as `data-wp-each` items added after hydration, now show their fallback on sites enforcing Trusted Types while their value is pending ([#84924](https://github.com/WordPress/gutenberg/pull/84924)).
- Reject malformed Interactivity directive names whose text after `data-wp-` begins with a hyphen, including offset-zero `--` forms that would create an empty prefix, aligning client-side validation with WordPress Core ([#81023](https://github.com/WordPress/gutenberg/pull/81023)).
- `data-wp-html` now follows its value on every update and after navigation: tokens render, pending values keep the content, anything else empties the element. Throwing getters and functions no longer break rendering. ([#84652](https://github.com/WordPress/gutenberg/pull/84652)).
- `data-wp-html` is ignored, with a warning, when combined with `data-wp-text`, `data-wp-each` or `data-wp-router-region` on the same element. ([#84652](https://github.com/WordPress/gutenberg/pull/84652)).
diff --git a/packages/interactivity/src/directives/html.ts b/packages/interactivity/src/directives/html.ts
index 5034e468bdff60..917c80834a4529 100644
--- a/packages/interactivity/src/directives/html.ts
+++ b/packages/interactivity/src/directives/html.ts
@@ -4,6 +4,7 @@ import { directive, isDefaultDirectiveSuffix } from '../hooks';
import { isPlainObject, useLayoutEffect, warn } from '../utils';
import { PENDING_GETTER } from '../proxies/state';
import { warnUniqueIdNotSupported } from './utils/warnings';
+import { getHtmlFallback } from '../vdom';
import { emptyHTML, isDangerousHTML, getDangerousHTML } from '../html';
/**
@@ -26,10 +27,15 @@ directive(
// Serialized HTML last applied or rejected; non-token values reset it
// to empty HTML so returning tokens are applied again after a removal.
const lastAppliedKey = useRef< string | undefined >( undefined );
- // Fixed at the first render. Preact writes only when creating the node;
- // all later writes, including navigation updates, belong to the effect.
+ // The value-rule state belongs to a host element, not its wrapper vnode.
+ const previousHost = useRef< HTMLElement | null >( null );
+ // Inputs are compared inside the effect so host replacements can be
+ // detected on every commit without repeating unchanged value-rule work.
+ const previousInputs = useRef< unknown[] | null >( null );
+ // Preact always receives empty HTML; fallback copies and later writes
+ // belong to this directive's layout effect.
const preactHtml = useRef< {
- /** Empty HTML or the preserved fallback applied only at creation. */
+ /** The policy-free empty HTML value Preact can safely apply. */
__html: string;
} >();
// Evaluated value, or undefined when evaluation or its function throws.
@@ -67,13 +73,53 @@ directive(
const trusted = isDangerousHTML( result );
// Current token payload or the policy-free removal value.
const resolvedValue = trusted ? getDangerousHTML( result ) : emptyHTML;
+ // The detached fallback captured when this vnode was built.
+ const fallback = getHtmlFallback( element );
- // Hydration never writes Preact's __html. Apply the current value only
- // after commit, adopting exactly matching unwritten server nodes.
+ // Apply after commit; matching server nodes or content kept for the
+ // representation can be adopted without another innerHTML write. The
+ // vnode dependency makes this run for possible host replacements too.
useLayoutEffect( () => {
+ const node = ( element.ref as RefObject< HTMLElement > ).current!;
+ const hostChanged = previousHost.current !== node;
+ const inputs = [
+ result,
+ active,
+ preserveContent,
+ serverHTMLRepresentation,
+ trusted,
+ resolvedValue,
+ element.ref,
+ ];
+ const inputsChanged =
+ previousInputs.current === null ||
+ inputs.some(
+ ( input, index ) =>
+ input !== previousInputs.current![ index ]
+ );
+ previousInputs.current = inputs;
+
+ if ( hostChanged ) {
+ previousHost.current = node;
+ lastAppliedKey.current = undefined;
+ if (
+ active &&
+ preserveContent &&
+ ! node.hasChildNodes() &&
+ fallback?.hasChildNodes()
+ ) {
+ node.append(
+ node.ownerDocument.importNode( fallback, true )
+ );
+ }
+ }
+
if ( ! active ) {
return;
}
+ if ( ! hostChanged && ! inputsChanged ) {
+ return;
+ }
if ( preserveContent ) {
// Content kept for the server representation may differ from
// what was last applied, so the next token is compared with the
@@ -92,7 +138,6 @@ directive(
if ( lastAppliedKey.current === key ) {
return;
}
- const node = ( element.ref as RefObject< HTMLElement > ).current!;
if (
lastAppliedKey.current === undefined &&
node.innerHTML === key
@@ -121,14 +166,15 @@ directive(
serverHTMLRepresentation,
trusted,
resolvedValue,
+ fallback,
element.ref,
+ element,
] );
if ( active ) {
- preactHtml.current ??=
- preserveContent && element.props.dangerouslySetInnerHTML?.__html
- ? element.props.dangerouslySetInnerHTML
- : { __html: emptyHTML as unknown as string };
+ preactHtml.current ??= {
+ __html: emptyHTML as unknown as string,
+ };
element.props.dangerouslySetInnerHTML = preactHtml.current;
element.props.children = undefined;
}
diff --git a/packages/interactivity/src/directives/test/html.jsdom.test.ts b/packages/interactivity/src/directives/test/html.jsdom.test.ts
index eb9b5c98d3f9ae..62cc0bfd400fa8 100644
--- a/packages/interactivity/src/directives/test/html.jsdom.test.ts
+++ b/packages/interactivity/src/directives/test/html.jsdom.test.ts
@@ -597,6 +597,403 @@ describe( 'data-wp-html', () => {
);
} );
+ it( 'uses a replaced directive host’s fallback and resolves its own pending value', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const sharedHTML = 'Old host HTML ';
+ runtime.populateServerData( {
+ state: {
+ [ namespace ]: {
+ html: runtime.asDangerousHTML( sharedHTML ),
+ late: {},
+ },
+ },
+ derivedStateClosures: { [ namespace ]: [ 'state.late' ] },
+ } );
+ const firstRegion = createRegion(
+ namespace,
+ ''
+ );
+ const mount = document.createElement( 'main' );
+ const firstTree = runtime.toVdom( firstRegion );
+ document.body.appendChild( mount );
+ runtime.render( firstTree, mount );
+ const firstHost = mount.querySelector( '[data-wp-html]' )!;
+ expect( firstHost.innerHTML ).toBe( sharedHTML );
+
+ const secondRegion = createRegion(
+ namespace,
+ 'Second fallback
'
+ );
+ const secondTree = runtime.toVdom( secondRegion );
+ runtime.render( secondTree, mount );
+
+ const secondHost = mount.querySelector( '[data-wp-html]' )!;
+ expect( secondHost ).not.toBe( firstHost );
+ expect( secondHost.innerHTML ).toBe( 'Second fallback
' );
+
+ await runtime.act( async () => {
+ runtime.store( namespace, {
+ state: {
+ get late() {
+ return runtime.asDangerousHTML( sharedHTML );
+ },
+ },
+ } );
+ } );
+ expect( secondHost.innerHTML ).toBe( sharedHTML );
+ } );
+
+ it( 'renders a token on a replacement host when it matches the previous host’s HTML', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const sharedHTML = 'Shared host HTML ';
+ runtime.store( namespace, {
+ state: { html: runtime.asDangerousHTML( sharedHTML ) },
+ } );
+ const firstRegion = createRegion(
+ namespace,
+ ''
+ );
+ const mount = document.createElement( 'main' );
+ const firstTree = runtime.toVdom( firstRegion );
+ document.body.appendChild( mount );
+ runtime.render( firstTree, mount );
+ const firstHost = mount.querySelector( '[data-wp-html]' )!;
+ expect( firstHost.innerHTML ).toBe( sharedHTML );
+
+ const secondRegion = createRegion(
+ namespace,
+ 'Second fallback
'
+ );
+ const secondTree = runtime.toVdom( secondRegion );
+ runtime.render( secondTree, mount );
+
+ const secondHost = mount.querySelector( '[data-wp-html]' )!;
+ expect( secondHost ).not.toBe( firstHost );
+ expect( secondHost.innerHTML ).toBe( sharedHTML );
+ } );
+
+ it( 'copies script-free fallbacks to every item without changing the template', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const items: Array< { html: unknown } > = [];
+ const { state } = runtime.store( namespace, {
+ state: { list: items },
+ } );
+ const region = createRegion(
+ namespace,
+ ''
+ );
+ const template = region.querySelector( 'template' )!;
+ const templateHTML = template.innerHTML;
+ const templateParagraph = template.content.querySelector( 'p' );
+ const representation = { wpServerHtml: true };
+
+ await runtime.act( () =>
+ runtime.hydrate( runtime.toVdom( region ), region.parentNode! )
+ );
+ await runtime.act( () => {
+ state.list.push( { html: representation } );
+ } );
+ const firstTarget = region.querySelector( 'li div' )!;
+ const firstParagraph = firstTarget.querySelector( 'p' );
+ expect( firstTarget.innerHTML ).toBe(
+ 'Loading
'
+ );
+ expect( firstTarget.querySelector( 'script' ) ).toBeNull();
+ expect(
+ firstTarget
+ .querySelector( 'template' )!
+ .content.querySelector( 'script' )
+ ).toBeNull();
+ expect( firstParagraph ).not.toBe( templateParagraph );
+
+ await runtime.act( () => {
+ state.list.push(
+ { html: representation },
+ { html: representation }
+ );
+ } );
+ const targets = Array.from( region.querySelectorAll( 'li div' ) );
+ const paragraphs = targets.map( ( target ) =>
+ target.querySelector( 'p' )
+ );
+ expect( targets ).toHaveLength( 3 );
+ expect( targets.map( ( target ) => target.innerHTML ) ).toEqual( [
+ 'Loading
',
+ 'Loading
',
+ 'Loading
',
+ ] );
+ expect( new Set( paragraphs ).size ).toBe( 3 );
+ expect( paragraphs ).not.toContain( templateParagraph );
+
+ await runtime.act( () => {
+ state.list.splice( 0, 1 );
+ } );
+ await runtime.act( () => {
+ state.list.push( { html: representation } );
+ } );
+ expect(
+ Array.from(
+ region.querySelectorAll( 'li div' ),
+ ( target ) => target.innerHTML
+ )
+ ).toEqual( Array( 3 ).fill( 'Loading
' ) );
+ expect( template.innerHTML ).toBe( templateHTML );
+ } );
+
+ it( 'uses the fallback snapshot captured before its live source changes', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ runtime.store( namespace, {
+ state: { html: { wpServerHtml: true } },
+ } );
+ const source = createRegion(
+ namespace,
+ ''
+ );
+ const tree = runtime.toVdom( source );
+ source.querySelector( '[data-wp-html]' )!.innerHTML =
+ 'Changed after capture ';
+ source.remove();
+
+ const mount = document.createElement( 'main' );
+ document.body.appendChild( mount );
+ runtime.render( tree, mount );
+
+ expect( mount.querySelector( '[data-wp-html]' )!.innerHTML ).toBe(
+ 'At capture
'
+ );
+ } );
+
+ it( 'keeps a created fallback through unrelated renders and never restores it after removal', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const item: { html: unknown; active: boolean } = {
+ html: { wpServerHtml: true },
+ active: false,
+ };
+ const { state } = runtime.store( namespace, {
+ state: { list: [ item ] },
+ } );
+ const region = createRegion(
+ namespace,
+ ''
+ );
+ await runtime.act( () =>
+ runtime.hydrate( runtime.toVdom( region ), region.parentNode! )
+ );
+ const target = region.querySelector< HTMLElement >( 'li div' )!;
+ const paragraph = target.querySelector( 'p' );
+ const writes = recordHtmlWrites( target );
+ const mutations: MutationRecord[] = [];
+ const observer = new MutationObserver( ( records ) =>
+ mutations.push( ...records )
+ );
+ observer.observe( target, { childList: true } );
+
+ await runtime.act( () => {
+ state.list[ 0 ].active = true;
+ } );
+ await runtime.act( () => {
+ state.list[ 0 ].active = false;
+ } );
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ expect( target.querySelector( 'p' ) ).toBe( paragraph );
+ expect( writes ).toEqual( [] );
+ expect( mutations ).toHaveLength( 0 );
+
+ await runtime.act( () => {
+ state.list[ 0 ].html = null;
+ } );
+ expect( target ).toBeEmptyDOMElement();
+ await runtime.act( () => {
+ state.list[ 0 ].html = { wpServerHtml: true };
+ } );
+ expect( target ).toBeEmptyDOMElement();
+ await runtime.act( () => {
+ state.list[ 0 ].html = runtime.asDangerousHTML( 'a ' );
+ } );
+ expect( target.innerHTML ).toBe( 'a ' );
+ await runtime.act( () => {
+ state.list[ 0 ].html = { wpServerHtml: true };
+ } );
+ expect( target.innerHTML ).toBe( 'a ' );
+ expect( writes ).toEqual( [ '', 'a ' ] );
+ observer.disconnect();
+ } );
+
+ it( 'changes child nodes once for different token HTML and adopts matching fallback HTML', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const items: Array< { html: unknown } > = [];
+ const { state } = runtime.store( namespace, {
+ state: { list: items },
+ } );
+ const region = createRegion(
+ namespace,
+ ''
+ );
+ await runtime.act( () =>
+ runtime.hydrate( runtime.toVdom( region ), region.parentNode! )
+ );
+ await runtime.act( () => {
+ state.list.push(
+ { html: { wpServerHtml: true } },
+ { html: { wpServerHtml: true } }
+ );
+ } );
+ const targets = Array.from(
+ region.querySelectorAll< HTMLElement >( 'li div' )
+ );
+ const paragraphs = targets.map( ( target ) =>
+ target.querySelector( 'p' )
+ );
+ const writes = targets.map( recordHtmlWrites );
+ const mutations = targets.map( () => [] as MutationRecord[] );
+ const observers = targets.map( ( target, index ) => {
+ const observer = new MutationObserver( ( records ) =>
+ mutations[ index ].push( ...records )
+ );
+ observer.observe( target, { childList: true } );
+ return observer;
+ } );
+
+ await runtime.act( () => {
+ state.list[ 0 ].html = runtime.asDangerousHTML( 'a ' );
+ } );
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ expect( targets[ 0 ].innerHTML ).toBe( 'a ' );
+ expect( writes[ 0 ] ).toEqual( [ 'a ' ] );
+ expect( mutations[ 0 ] ).toHaveLength( 1 );
+
+ await runtime.act( () => {
+ state.list[ 1 ].html = runtime.asDangerousHTML( 'Loading
' );
+ } );
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ expect( targets[ 1 ].querySelector( 'p' ) ).toBe( paragraphs[ 1 ] );
+ expect( writes[ 1 ] ).toEqual( [] );
+ expect( mutations[ 1 ] ).toHaveLength( 0 );
+ observers.forEach( ( observer ) => observer.disconnect() );
+ } );
+
+ it( 'keeps fallback directives and islands inert before and after the token replaces them', async () => {
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const otherNamespace = `${ namespace }-other`;
+ const { state } = runtime.store( namespace, {
+ state: { list: [] as Array< { html: unknown } >, text: 'dynamic' },
+ } );
+ let initCalls = 0;
+ runtime.store( otherNamespace, {
+ callbacks: {
+ init() {
+ initCalls++;
+ },
+ },
+ } );
+ const region = createRegion(
+ namespace,
+ ``
+ );
+ await runtime.act( () =>
+ runtime.hydrate( runtime.toVdom( region ), region.parentNode! )
+ );
+ await runtime.act( () => {
+ state.list.push( { html: { wpServerHtml: true } } );
+ } );
+ const target = region.querySelector( 'li div' )!;
+ await runtime.act( () => {
+ state.text = 'changed';
+ } );
+ expect( target.querySelector( 'span' ) ).toHaveTextContent( 'static' );
+ expect( initCalls ).toBe( 0 );
+
+ await runtime.act( () => {
+ state.list[ 0 ].html = runtime.asDangerousHTML( 'Live ' );
+ } );
+ expect( target.innerHTML ).toBe( 'Live ' );
+ expect( initCalls ).toBe( 0 );
+ } );
+
+ it( 'empties rejected writes after a created fallback and never shows one for a rejected token at creation', async () => {
+ vi.stubGlobal( 'SCRIPT_DEBUG', true );
+ const runtime = await loadConflictRuntime();
+ const namespace = uniqueNamespace();
+ const items: Array< { html: unknown } > = [];
+ const { state } = runtime.store( namespace, {
+ state: { list: items },
+ } );
+ const region = createRegion(
+ namespace,
+ ''
+ );
+ await runtime.act( () =>
+ runtime.hydrate( runtime.toVdom( region ), region.parentNode! )
+ );
+ const descriptor = Object.getOwnPropertyDescriptor(
+ Element.prototype,
+ 'innerHTML'
+ )!;
+ const rejectedValues = new Set( [ 'x ', 'y ' ] );
+ Object.defineProperty( Element.prototype, 'innerHTML', {
+ configurable: true,
+ get() {
+ return descriptor.get!.call( this );
+ },
+ set( this: Element, value: string ) {
+ if ( rejectedValues.has( value ) ) {
+ throw new TypeError( `rejected ${ value }` );
+ }
+ descriptor.set!.call( this, value );
+ },
+ } );
+ const warnings = vi.spyOn( console, 'warn' );
+ const warningStart = warnings.mock.calls.length;
+ try {
+ await runtime.act( () => {
+ state.list.push( { html: { wpServerHtml: true } } );
+ } );
+ const existing = region.querySelector( 'li div' )!;
+ expect( existing.innerHTML ).toBe( 'Loading
' );
+ await runtime.act( () => {
+ state.list[ 0 ].html = runtime.asDangerousHTML( 'x ' );
+ } );
+ await runtime.act( () => {
+ state.list.push( {
+ html: runtime.asDangerousHTML( 'y ' ),
+ } );
+ } );
+ expect(
+ Array.from(
+ region.querySelectorAll( 'li div' ),
+ ( target ) => target.innerHTML
+ )
+ ).toEqual( [ '', '' ] );
+ expect( warnings.mock.calls.slice( warningStart ) ).toHaveLength(
+ 2
+ );
+ expect( warnings.mock.calls.slice( warningStart ) ).toEqual( [
+ [
+ expect.stringContaining(
+ 'browser rejected this HTML value'
+ ),
+ ],
+ [
+ expect.stringContaining(
+ 'browser rejected this HTML value'
+ ),
+ ],
+ ] );
+ expect( console ).toHaveWarnedWith(
+ expect.stringContaining( 'browser rejected this HTML value' )
+ );
+ } finally {
+ Object.defineProperty( Element.prototype, 'innerHTML', descriptor );
+ }
+ } );
+
it( 'empties content and restores scope when a function value throws', async () => {
const namespace = uniqueNamespace();
const { state } = store( namespace, {
@@ -774,10 +1171,11 @@ describe( 'data-wp-html', () => {
} );
/**
- * Loads an isolated runtime so each test can observe deduplicated warnings.
- * Its renderer hook is restored by the file's afterEach cleanup.
+ * Loads an isolated runtime so each test can observe deduplicated warnings and
+ * render with the matching Preact instance. Its renderer hook is restored by
+ * the file's afterEach cleanup.
*
- * @return The directive runtime and its matching Preact renderer.
+ * @return The directive runtime and its matching Preact renderers.
*/
async function loadConflictRuntime() {
vi.resetModules();
@@ -786,8 +1184,9 @@ async function loadConflictRuntime() {
const { store: register, populateServerData: populate } =
await import( '../../store' );
const { asDangerousHTML: token } = await import( '../../html' );
+ const { hydrate: hydrateRegion, render: renderTree } =
+ await import( 'preact' );
const { getContext: readContext } = await import( '../../scopes' );
- const { hydrate: hydrateRegion } = await import( 'preact' );
const { act: flush } = await import( 'preact/test-utils' );
return {
// Converts the test markup using this runtime's directive registry.
@@ -802,6 +1201,8 @@ async function loadConflictRuntime() {
getContext: readContext,
// Hydrates markup with the matching Preact instance.
hydrate: hydrateRegion,
+ // Renders replacement markup with the matching Preact instance.
+ render: renderTree,
// Flushes reactive updates with the matching Preact instance.
act: flush,
};
diff --git a/packages/interactivity/src/vdom.ts b/packages/interactivity/src/vdom.ts
index 7f0bcfe6b2a743..238bcddd47129f 100644
--- a/packages/interactivity/src/vdom.ts
+++ b/packages/interactivity/src/vdom.ts
@@ -1,7 +1,10 @@
-import { h, type ComponentChild, type JSX } from 'preact';
+import { h, type ComponentChild, type JSX, type VNode } from 'preact';
import { warn } from './utils';
import { type DirectiveEntry } from './hooks';
+/** Associates opaque HTML props with detached, script-free fallback snapshots. */
+const htmlFallbacks = new WeakMap< object, DocumentFragment >();
+
const directivePrefix = `data-wp-`;
const namespaces: Array< string | null > = [];
const currentNamespace = () => namespaces[ namespaces.length - 1 ] ?? null;
@@ -74,6 +77,53 @@ function parseDirectiveName( directiveName: string ): {
// forward slashes. References don't have any restrictions.
const nsPathRegExp = /^([\w_\/-]+)::(.+)$/;
+/**
+ * Copies a node tree into a fragment, omitting script elements recursively.
+ * Template contents are traversed separately because they are not ordinary
+ * child nodes of their template element.
+ *
+ * @param source The node whose child nodes are copied.
+ * @param target The fragment or cloned node that receives those children.
+ */
+function copyFallbackChildren( source: Node, target: Node ): void {
+ for ( const child of Array.from( source.childNodes ) ) {
+ if (
+ child.nodeType === 1 &&
+ ( child as Element ).localName === 'script'
+ ) {
+ continue;
+ }
+
+ const clone = child.cloneNode( false );
+ target.appendChild( clone );
+ if (
+ child.nodeType === 1 &&
+ ( child as Element ).localName === 'template'
+ ) {
+ copyFallbackChildren(
+ ( child as HTMLTemplateElement ).content,
+ ( clone as HTMLTemplateElement ).content
+ );
+ } else {
+ copyFallbackChildren( child, clone );
+ }
+ }
+}
+
+/**
+ * Reads the script-free fallback snapshot recorded for an element vnode.
+ * Callers must leave the snapshot detached and unmodified; insert a clone.
+ *
+ * @param element The element vnode passed to a directive callback.
+ * @return The detached fallback snapshot, or `undefined` when absent.
+ */
+export function getHtmlFallback(
+ element: VNode< any >
+): DocumentFragment | undefined {
+ const htmlProps = element.props.dangerouslySetInnerHTML;
+ return htmlProps && htmlFallbacks.get( htmlProps );
+}
+
/**
* Parses the namespace and value from a directive attribute value.
*
@@ -264,9 +314,18 @@ export function toVdom( root: Node ): ComponentChild {
// vnodes, since the directive writes to the DOM directly rather
// than through Preact's own props diffing (see
// `directives/html.ts`).
- props.dangerouslySetInnerHTML = {
+ const htmlProps = {
__html: elementNode.innerHTML,
};
+ props.dangerouslySetInnerHTML = htmlProps;
+ if ( hasDefaultHtmlDirective ) {
+ // Preserve a script-free snapshot at vnode construction time so a
+ // created host can copy it without parsing an HTML string.
+ const snapshot =
+ elementNode.ownerDocument.createDocumentFragment();
+ copyFallbackChildren( elementNode, snapshot );
+ htmlFallbacks.set( htmlProps, snapshot );
+ }
} else if ( localName === 'template' ) {
props.content = [
...( elementNode as HTMLTemplateElement ).content.childNodes,
diff --git a/test/e2e/specs/interactivity/directive-html.spec.ts b/test/e2e/specs/interactivity/directive-html.spec.ts
index b524fde43e096d..cf50f8f96ad1c0 100644
--- a/test/e2e/specs/interactivity/directive-html.spec.ts
+++ b/test/e2e/specs/interactivity/directive-html.spec.ts
@@ -1,5 +1,82 @@
+import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
+/** A Trusted Types violation captured from the browser document. */
+type BrowserViolation = {
+ /** The CSP directive that rejected the operation. */
+ directive: string;
+ /** The browser-provided sample of the rejected value. */
+ sample: string;
+};
+
+/**
+ * Loads the directive fixture under Trusted Types enforcement.
+ *
+ * @param page The page that will load the enforcing fixture.
+ * @param link The fixture URL before its enforcement query parameter.
+ * @param policyMode Optional default policy to install before scripts.
+ * @return Page errors observed during this fixture load and its interactions.
+ */
+async function openEnforcingPage(
+ page: Page,
+ link: string,
+ policyMode?: 'transform'
+): Promise< Error[] > {
+ const pageErrors: Error[] = [];
+ page.on( 'pageerror', ( error ) => pageErrors.push( error ) );
+ await page.addInitScript( ( mode ) => {
+ const trackedWindow = window as typeof window & {
+ htmlViolations: BrowserViolation[];
+ };
+ trackedWindow.htmlViolations = [];
+ document.addEventListener( 'securitypolicyviolation', ( event ) => {
+ trackedWindow.htmlViolations.push( {
+ directive: event.violatedDirective,
+ sample: event.sample,
+ } );
+ } );
+ if ( mode === 'transform' ) {
+ ( window as any ).trustedTypes.createPolicy( 'default', {
+ createHTML: ( html: string ) =>
+ html.replace( //g, '' ),
+ } );
+ }
+ }, policyMode ?? null );
+ const url = new URL( link );
+ url.searchParams.set( 'require_trusted_types', 'true' );
+ const response = await page.goto( url.href );
+ expect( response?.headers()[ 'content-security-policy' ] ).toBe(
+ "require-trusted-types-for 'script'"
+ );
+ await expect( page.getByTestId( 'hydration' ) ).toHaveText( 'hydrated' );
+ return pageErrors;
+}
+
+/**
+ * Waits for queued CSP events and returns the page's captured violations.
+ *
+ * @param page Page whose document recorded violations.
+ * @return Trusted Types violations observed so far.
+ */
+async function readViolations( page: Page ): Promise< BrowserViolation[] > {
+ await page.evaluate(
+ () =>
+ new Promise< void >( ( resolve ) =>
+ requestAnimationFrame( () =>
+ requestAnimationFrame( () => resolve() )
+ )
+ )
+ );
+ return page.evaluate(
+ () =>
+ (
+ window as typeof window & {
+ htmlViolations: BrowserViolation[];
+ }
+ ).htmlViolations
+ );
+}
+
test.describe( 'data-wp-html', () => {
test.beforeAll( async ( { interactivityUtils: utils } ) => {
await utils.activatePlugins();
@@ -137,4 +214,114 @@ test.describe( 'data-wp-html', () => {
.getByTestId( 'unique-id-fallback' )
).toBeVisible();
} );
+
+ test( 'shows the fallback of created items, without their scripts, under Trusted Types enforcement', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ const pageErrors = await openEnforcingPage(
+ page,
+ utils.getLink( 'test/directive-html' )
+ );
+ // Records each item's content when it is inserted, before paint.
+ await page.evaluate( () => {
+ const trackedWindow = window as typeof window & {
+ insertedHTML: string[];
+ };
+ trackedWindow.insertedHTML = [];
+ const list = document.querySelector(
+ '[data-testid="fallback items"]'
+ )!;
+ new MutationObserver( ( records ) => {
+ for ( const record of records ) {
+ for ( const node of Array.from( record.addedNodes ) ) {
+ if ( node instanceof HTMLLIElement ) {
+ trackedWindow.insertedHTML.push(
+ node.querySelector( 'div' )!.innerHTML
+ );
+ }
+ }
+ }
+ } ).observe( list, { childList: true } );
+ } );
+
+ await page.getByTestId( 'add fallback' ).click();
+ await page.getByTestId( 'add two fallbacks' ).click();
+ const items = page.getByTestId( 'fallback items' ).locator( 'li div' );
+ await expect( items ).toHaveCount( 3 );
+ expect(
+ await items.evaluateAll( ( elements ) =>
+ elements.map( ( element ) => element.innerHTML )
+ )
+ ).toEqual( Array( 3 ).fill( '
Loading
' ) );
+ expect(
+ await page.evaluate(
+ () =>
+ ( window as typeof window & { insertedHTML: string[] } )
+ .insertedHTML
+ )
+ ).toEqual( Array( 3 ).fill( 'Loading
' ) );
+
+ await page.getByTestId( 'add script items' ).click();
+ const scriptItems = page
+ .getByTestId( 'script items' )
+ .locator( 'li div' );
+ await expect( scriptItems ).toHaveCount( 2 );
+ expect(
+ await scriptItems.evaluateAll( ( elements ) =>
+ elements.map( ( element ) => element.innerHTML )
+ )
+ ).toEqual( [ 'Loading
', 'Loading
' ] );
+ expect(
+ await page.evaluate(
+ () =>
+ ( window as typeof window & { fallbackRuns?: number } )
+ .fallbackRuns
+ )
+ ).toBeUndefined();
+
+ expect( await readViolations( page ) ).toEqual( [] );
+ expect( pageErrors ).toEqual( [] );
+ } );
+
+ test( 'never passes the fallback of created items through a default Trusted Types policy', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ // The default policy turns paragraphs into `em` elements, so any
+ // string write of the fallback would change it.
+ const pageErrors = await openEnforcingPage(
+ page,
+ utils.getLink( 'test/directive-html' ),
+ 'transform'
+ );
+ await page.getByTestId( 'add fallback' ).click();
+ await expect
+ .poll( () =>
+ page
+ .getByTestId( 'fallback items' )
+ .locator( 'li div' )
+ .innerHTML()
+ )
+ .toBe( 'Loading
' );
+ expect( await readViolations( page ) ).toEqual( [] );
+ expect( pageErrors ).toEqual( [] );
+ } );
+
+ test( 'shows a pending derived value fallback until its late getter resolves under Trusted Types enforcement', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ const pageErrors = await openEnforcingPage(
+ page,
+ utils.getLink( 'test/directive-html' )
+ );
+ await page.getByTestId( 'add lazy item' ).click();
+ const target = page.getByTestId( 'lazy items' ).locator( 'li div' );
+ await expect.poll( () => target.innerHTML() ).toBe( 'Loading
' );
+ await page.getByTestId( 'register lazy' ).click();
+ await expect.poll( () => target.innerHTML() ).toBe( 'Late ' );
+ expect( await readViolations( page ) ).toEqual( [] );
+ expect( pageErrors ).toEqual( [] );
+ } );
} );
diff --git a/test/e2e/specs/interactivity/router-html.spec.ts b/test/e2e/specs/interactivity/router-html.spec.ts
index 8394f128239cdd..65160740b3e56f 100644
--- a/test/e2e/specs/interactivity/router-html.spec.ts
+++ b/test/e2e/specs/interactivity/router-html.spec.ts
@@ -13,6 +13,21 @@ test.describe( 'Router HTML content', () => {
attributes: { page: 'one', value: 'T', next },
} );
}
+ const fallbackDestination = await utils.addPostWithBlock(
+ 'test/router-html',
+ {
+ alias: 'fallback destination',
+ attributes: { page: 'two', value: 'pending' },
+ }
+ );
+ await utils.addPostWithBlock( 'test/router-html', {
+ alias: 'fallback source',
+ attributes: {
+ page: 'one',
+ value: 'T',
+ next: fallbackDestination,
+ },
+ } );
} );
test.afterAll( async ( { interactivityUtils: utils } ) => {
@@ -97,4 +112,46 @@ test.describe( 'Router HTML content', () => {
await originalKept.dispose();
} );
}
+
+ test( 'shows a script-free fallback every time navigation creates the element', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ const pageErrors: Error[] = [];
+ page.on( 'pageerror', ( error ) => pageErrors.push( error ) );
+ await page.goto( utils.getLink( 'fallback source' ) );
+ const nextLink = page.getByRole( 'link', {
+ name: 'Next',
+ exact: true,
+ } );
+ await nextLink.click();
+ const fallback = page.getByTestId( 'added-html' );
+ await expect
+ .poll( () => fallback.innerHTML() )
+ .toBe( 'B fallback
' );
+ expect(
+ await page.evaluate(
+ () =>
+ ( window as typeof window & { fallbackRuns?: number } )
+ .fallbackRuns
+ )
+ ).toBeUndefined();
+
+ await page.goBack();
+ await expect( page.getByTestId( 'page-content' ) ).toHaveText(
+ 'content from one'
+ );
+ await page.getByRole( 'link', { name: 'Next', exact: true } ).click();
+ await expect
+ .poll( () => fallback.innerHTML() )
+ .toBe( 'B fallback
' );
+ expect(
+ await page.evaluate(
+ () =>
+ ( window as typeof window & { fallbackRuns?: number } )
+ .fallbackRuns
+ )
+ ).toBeUndefined();
+ expect( pageErrors ).toEqual( [] );
+ } );
} );