Skip to content
Merged
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
37 changes: 37 additions & 0 deletions docs/reference-guides/interactivity-api/directives-and-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,43 @@ The `wp-text` directive is executed:

The returned value is used to change the inner content of the element: `<div>value</div>`.

### `wp-html`

It sets the inner HTML of an element, rendering markup instead of the plain text `wp-text` would produce. Plain strings, `null`, and any other value that isn't explicitly marked as trusted are ignored, leaving the element's existing content in place — this is what lets a derived-state getter return `null` while content is loading and keep the server-rendered fallback visible.

Because rendering arbitrary HTML is a security concern, `wp-html` only accepts a value returned by `asDangerousHTML()`, exported from `@wordpress/interactivity`. Passing a plain string directly does nothing, by design: the plugin's JavaScript has to explicitly opt in to rendering that string as markup.

```html
<div data-wp-interactive="myPlugin">
<article data-wp-html="state.description">
<p>Loading description.</p>
</article>
</div>
```

```js
import { asDangerousHTML, store } from '@wordpress/interactivity';

store( 'myPlugin', {
state: {
get description() {
return asDangerousHTML(
'<p>A <strong>formatted</strong> description.</p>'
);
},
},
} );
```

#### Security contract

- **`asDangerousHTML()` does not sanitize.** It performs no processing at all on the HTML it's given — the same responsibility a developer takes on using React's `dangerouslySetInnerHTML`. Treating untrusted input as trusted here is an XSS risk. Never pass it raw user-supplied content or a `context` value directly: `context` is meant for selecting *which* trusted markup to show (e.g. an ID or a flag), while the actual HTML should come from `state`, computed by your plugin's own code from a source it trusts (its own bundled markup, or a response from an endpoint it controls).
- **The returned value is opaque and identity-based, not content-based.** It carries no inspectable trace of the HTML it wraps. Adding it to `data-wp-context`, spreading it into a new object, or serializing it as JSON does not carry the "trusted" marker along with it — only a `wp-html` reference that resolves directly to the exact value `asDangerousHTML()` returned is ever rendered.
- **This package creates no Trusted Types policy.** On a site that enforces Trusted Types with no default policy, passing a plain string causes the browser to reject the write, and the element's existing content (the server-rendered fallback, or the last HTML that did render successfully) is left in place. The supported way to render on such a site is to pass a `TrustedHTML` value from the site's own policy: `asDangerousHTML( myPolicy.createHTML( markup ) )`. This package deliberately doesn't create a policy of its own — that's a decision for the site to make.
- **"Inert" means inert to the Interactivity API only.** Directives inside the HTML `wp-html` inserts are never processed, and interactive islands nested inside it are not hydrated (nested [client-side navigation](./core-concepts/client-side-navigation.md) regions aren't excluded from processing yet). But the inserted content is still live browser HTML: a `<script>` tag inside it won't execute, but that's an artifact of how `innerHTML` works, not a security boundary you should rely on. Content that isn't safe to render as HTML isn't made safe by going through `wp-html`.

The server leaves `wp-html` unprocessed, so the element's server-rendered content serves as a fallback until hydration runs (and stays visible if JavaScript never loads, or on a version of the Interactivity API that doesn't support this directive yet).

### `wp-on`

<div class="callout callout-info">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "test/directive-html",
"title": "E2E Interactivity tests - directive html",
"category": "text",
"icon": "heart",
"description": "",
"supports": {
"interactivity": true
},
"textdomain": "e2e-interactivity",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/**
* HTML for testing the directive `data-wp-html`.
*
* @package gutenberg-test-interactive-blocks
*/
?>

<div data-wp-interactive="directive-html">
<div data-wp-html="state.html" data-testid="show state html">
<p data-testid="fallback">Fallback content</p>
</div>

<button data-wp-on--click="actions.setHtml" data-testid="set html">
Set HTML
</button>

<button
data-wp-on--click="actions.setLoading"
data-testid="set loading"
>
Set Loading
</button>

<button
data-wp-on--click="actions.setPlainString"
data-testid="set plain string"
>
Set Plain String
</button>

<div data-wp-html--suffix="state.html" data-testid="ignores suffixes">
<span data-testid="suffix-fallback">Suffix fallback</span>
</div>

<div
data-wp-html---unique-id="state.html"
data-testid="ignores unique-ids"
>
<span data-testid="unique-id-fallback">Unique ID fallback</span>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php return array( 'dependencies' => array( '@wordpress/interactivity' ) );
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { store, asDangerousHTML } from '@wordpress/interactivity';

const { state } = store( 'directive-html', {
state: {
html: null,
},
actions: {
setHtml() {
state.html = asDangerousHTML(
'<strong data-testid="rendered-strong">Rendered HTML</strong>'
);
},
setLoading() {
state.html = null;
},
setPlainString() {
state.html = '<strong>Not rendered</strong>';
},
},
} );
4 changes: 4 additions & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Features

- Add the `data-wp-html` directive and `asDangerousHTML()` for rendering explicitly trusted HTML from reactive state. ([#83092](https://github.com/WordPress/gutenberg/pull/83092))

### Enhancements

- `getConfig` accepts a type parameter describing the config it returns, like `getContext` does, so consumers can read typed config values. It defaults to `any`, so existing calls are unaffected ([#71692](https://github.com/WordPress/gutenberg/pull/71692)).
Expand Down
129 changes: 129 additions & 0 deletions packages/interactivity/src/directives/html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useRef } from 'preact/hooks';
import type { RefObject } from 'preact';
import { directive, isDefaultDirectiveSuffix } from '../hooks';
import { useLayoutEffect, warn } from '../utils';
import { PENDING_GETTER } from '../proxies/state';
import { warnUniqueIdNotSupported } from './utils/warnings';
import { isDangerousHTML, getDangerousHTML } from '../html';

// A single, frozen object reused for every render of every `data-wp-html`
// element. Preact only writes `dangerouslySetInnerHTML` to the DOM itself
// when the `__html` value it's given differs from the previous render's; by
// never changing it, Preact's own diffing never touches this element's
// content, leaving that entirely to the `useLayoutEffect` below. This also
// keeps Preact from clearing the element when a render doesn't resolve to
// trusted HTML, which it would otherwise do for an element that previously
// had `dangerouslySetInnerHTML` but no longer does.
const STABLE_EMPTY_HTML = Object.freeze( { __html: '' } );

// data-wp-html — Inner HTML binding, for content explicitly trusted via `asDangerousHTML()`.
directive(
'html',
( { directives: { html: htmlEntries }, element, evaluate } ) => {
const entry = htmlEntries.find( isDefaultDirectiveSuffix );
// Persists the last trusted HTML value actually resolved, so a later
// value that isn't trusted HTML (a plain string, `null`, `undefined`,
// a pending getter…) leaves the existing content in place instead of
// clearing it.
const lastValue = useRef<
ReturnType< typeof getDangerousHTML > | undefined
>( undefined );
// The string (or serialized `TrustedHTML`) representation of the
// value last successfully written to the DOM, so the effect below
// can skip repeating a write — and, if it previously failed, a
// warning — for a value it has already applied. A `TrustedHTML`
// object returned fresh from a getter on every render wouldn't
// otherwise compare equal to the previous render's by reference.
const lastWrittenKey = useRef< string | undefined >( undefined );

if ( ! entry ) {
if ( globalThis.SCRIPT_DEBUG ) {
warn(
'The usage of data-wp-html--suffix is not supported. Please use data-wp-html instead.'
);
}
} else if ( entry.uniqueId ) {
if ( globalThis.SCRIPT_DEBUG ) {
warnUniqueIdNotSupported( 'html', entry.uniqueId );
}
} else {
let result;
try {
result = evaluate( entry );
} catch {
result = undefined;
}
if ( result !== PENDING_GETTER ) {
if ( typeof result === 'function' ) {
result = result();
}
if ( isDangerousHTML( result ) ) {
lastValue.current = getDangerousHTML( result );
}
}
}

const resolvedValue = lastValue.current;

// Preact doesn't apply `dangerouslySetInnerHTML` while hydrating, on
// the assumption that server-rendered content already matches what
// would be rendered. That assumption doesn't hold here: the server
// intentionally leaves `data-wp-html` unprocessed, so the element's
// content has to be applied here — the same way `data-wp-style`
// re-applies styles after hydration.
useLayoutEffect( () => {
if ( resolvedValue === undefined ) {
return;
}

const key = String( resolvedValue );
if ( lastWrittenKey.current === key ) {
return;
}

const node = ( element.ref as RefObject< HTMLElement > ).current!;

// Before the first write, the element still holds its
// server-rendered content. If that already serializes to the
// same HTML, keep its nodes instead of replacing them with
// identical ones, which would lose focus, selection, form input,
// or embedded media state.
if (
lastWrittenKey.current === undefined &&
node.innerHTML === key
) {
lastWrittenKey.current = key;
return;
}

try {
// `resolvedValue` may be a native `TrustedHTML` value from a
// site-provided policy, which the DOM lib types this project
// currently targets don't yet know `innerHTML` accepts.
node.innerHTML = resolvedValue as unknown as string;
lastWrittenKey.current = key;
} catch ( error ) {
// Most likely a site enforcing Trusted Types with no default
// policy, given a plain string. Leave the existing content
// (server-rendered, or from a previous successful write) in
// place rather than clearing it, and don't retry until the
// value itself changes.
lastWrittenKey.current = key;
if ( globalThis.SCRIPT_DEBUG ) {
warn(
`data-wp-html: the browser rejected this HTML value, so the element's existing content was left in place. ${ error }`
);
}
}
}, [ resolvedValue, element.ref ] );

if ( resolvedValue === undefined ) {
// Nothing has ever resolved to trusted HTML for this element:
// leave the pre-existing (usually server-rendered) content as is.
return;
}

element.props.dangerouslySetInnerHTML = STABLE_EMPTY_HTML;
element.props.children = undefined;
}
);
1 change: 1 addition & 0 deletions packages/interactivity/src/directives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import './bind';
import './class';
import './context';
import './each';
import './html';
import './ignore';
import './init';
import './on';
Expand Down
Loading
Loading