From cb31bd6e07012828aeb071fe5edfabd5ec85bd4a Mon Sep 17 00:00:00 2001 From: luisherranz Date: Fri, 16 Oct 2026 15:20:24 +0200 Subject: [PATCH 1/5] Interactivity API: Show the data-wp-html fallback on created elements by copying its nodes --- packages/interactivity/src/directives/html.ts | 66 ++- .../src/directives/test/html.jsdom.test.ts | 409 +++++++++++++++++- packages/interactivity/src/vdom.ts | 63 ++- 3 files changed, 522 insertions(+), 16 deletions(-) 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, + '

First fallback

' + ); + 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, + '

First fallback

' + ); + 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, + '

At capture

' + ); + 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, From 9d93d1105811347054230fe29bccd74f3a9d1929 Mon Sep 17 00:00:00 2001 From: luisherranz Date: Fri, 16 Oct 2026 15:20:25 +0200 Subject: [PATCH 2/5] Interactivity API: Add e2e tests for created data-wp-html fallbacks --- .../directive-html/render.php | 35 ++++ .../interactive-blocks/directive-html/view.js | 49 +++++ .../interactive-blocks/router-html/render.php | 3 + .../interactivity/directive-html.spec.ts | 187 ++++++++++++++++++ .../specs/interactivity/router-html.spec.ts | 57 ++++++ 5 files changed, 331 insertions(+) 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 @@
+ + + + + +
    + +
+
    + +
+
    + +
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( [] ); + } ); } ); From 16f37e77232c052d9695a51f376f78ef5c71de6f Mon Sep 17 00:00:00 2001 From: luisherranz Date: Fri, 16 Oct 2026 15:20:25 +0200 Subject: [PATCH 3/5] Interactivity API: Document created data-wp-html fallbacks --- .../interactivity-api/directives-and-store.md | 4 +++- packages/interactivity/CHANGELOG.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/reference-guides/interactivity-api/directives-and-store.md b/docs/reference-guides/interactivity-api/directives-and-store.md index c97bf3753bcd27..2927b20cdad739 100644 --- a/docs/reference-guides/interactivity-api/directives-and-store.md +++ b/docs/reference-guides/interactivity-api/directives-and-store.md @@ -385,6 +385,8 @@ The element shows what its reference resolves to, calling it first when it is a To keep showing the fallback, return a pending value, not `null`. After [client-side navigation](/docs/reference-guides/interactivity-api/core-concepts/client-side-navigation.md), the element follows its value on the new page. Known limitation: when that value is `{ "wpServerHtml": true }`, the element keeps the content it had before navigating instead of the new page's server-rendered content. +Elements the client creates instead of hydrating, such as the items of a [`data-wp-each`](#wp-each) template or elements added by client-side navigation, also show their fallback while their value is pending or `{ "wpServerHtml": true }`. `