Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
);
?>

<div data-wp-interactive="directive-html">
Expand All @@ -28,6 +49,20 @@
<ul data-testid="created items">
<template data-wp-each="state.list"><li><div data-wp-html="state.empty"></div></li></template>
</ul>
<button data-wp-on--click="actions.addFallback" data-testid="add fallback">Add fallback</button>
<button data-wp-on--click="actions.addTwoFallbacks" data-testid="add two fallbacks">Add two fallbacks</button>
<button data-wp-on--click="actions.addLazyItem" data-testid="add lazy item">Add lazy item</button>
<button data-wp-on--click="actions.registerLazy" data-testid="register lazy">Register lazy value</button>
<button data-wp-on--click="actions.addScriptItems" data-testid="add script items">Add script items</button>
<ul data-testid="fallback items">
<template data-wp-each="state.fallbackItems"><li><div data-wp-html="context.item.html"><p>Loading</p></div></li></template>
</ul>
<ul data-testid="lazy items">
<template data-wp-each="state.lazyItems"><li><div data-wp-html="state.lazy"><p>Loading</p></div></li></template>
</ul>
<ul data-testid="script items">
<template data-wp-each="state.scriptItems"><li><div data-wp-html="context.item.html"><p>Loading</p><script>window.fallbackRuns = ( window.fallbackRuns || 0 ) + 1;</script></div></li></template>
</ul>
<button data-wp-on--click="actions.setRejected" data-testid="set rejected html">Set Rejected HTML</button>

<button data-wp-on--click="actions.setHtml" data-testid="set html">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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( '<b>Late</b>' )
);
},
},
} );
},
/** 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(
'<strong data-testid="rendered-strong">Rendered HTML</strong>'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ function ( $data ) {
<p data-testid="page-content">content from <?php echo esc_html( $page ); ?></p>
<div data-testid="kept-html" data-wp-html="state.html"><i><?php echo 'one' === $page ? 'one' : 'two'; ?></i></div>
<div data-testid="never-html" data-wp-html="state.never"><i><?php echo 'one' === $page ? 'never one' : 'never two'; ?></i></div>
<?php if ( 'two' === $page && 'pending' === $attributes['value'] ) : ?>
<div data-testid="added-html" data-wp-html="state.late"><p>B fallback</p><script>window.fallbackRuns = ( window.fallbackRuns || 0 ) + 1;</script></div>
<?php endif; ?>
<p data-testid="value" data-wp-text="state.value">not hydrated</p>
<button data-wp-on--click="actions.resolve">Resolve HTML</button>
<?php if ( ! empty( $attributes['next'] ) ) : ?>
Expand Down
1 change: 1 addition & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
66 changes: 56 additions & 10 deletions packages/interactivity/src/directives/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading