I did some thinking on what the best developer experience could be using the directives approach on hydration, and
started exploring a solution where the frontend component can still be authored like regular JSX.
The idea is to build upon the special save
function of the blocks, which is not a live React component, but returns JSX that's serialized into a HTML string and saved in the post. This could be a good place to connect the markup with the context, adding the directives in the serialization step.
Here is a rough draft of how it would look like:
// Editor → save.js
export const save = ( { attributes } ) => {
const { reset, time, isFinished } = useFrontendContext.save( 'example/coundown', ExampleCountdown( { attributes } ) );
return <div { ...useBlockProps.save() }>
<div className={ { 'is-finished': isFinished } }>{ time }</div>
<button onClick={ reset }>{ __( 'Reset', 'text-domain' ) }</button>
</div>
}
There are two steps to achieve this:
useFrontendContext.save
wraps the value in a proxy that tracks how the context is used, returning a data object for each property: { context: 'example/countdown', prop: 'isFinished', value: false }
useFrontendContext.save = ( namespace, context ) => new Proxy( context, () => ( {
get( target, prop, receiver ) {
return { context: namespace, prop: prop, value: Reflect.get( ...arguments ) };
},
} ) )
- The serializer outputs directive attributes, eg. turns
onClick
to wp:click
, and uses the data from the proxy.
The serializer would see this:
createElement( 'div', { blockProps }, [
createElement( 'div', {
className: { 'is-finished': { context: 'example/countdown', prop: 'isFinished', value: false } },
}, [ { context: 'example/countdown', prop: 'time', value: '00:30' } ] ),
createElement( 'button', {
onClick: { context: 'example/countdown', prop: 'reset', value: Function },
}, [ 'Reset' ] ),
] )
And generate this HTML:
<div class="wp-block-example-countdown aligncenter is-style-large">
<div class="" wp-class:is-finished="example/countdown.isFinished" wp-bind="example/countdown.time">00:30</div>
<button wp-on:click="example/countdown.reset">Reset</button>
</div>
The context can be defined in a flexible way. I looked at doing it component-like, with a constructor function that can contain state, set up side effects or other integrations:
// frontend.js
import { formatTime } from 'external-library';
export const ExampleCountdown = ( { attributes: { initial } } ) => {
let count = initial;
// Stuff to run on the frontend only.
useEffect( () => {
const tick = () => {
count--;
if ( count === 0 ) {
clearInterval( timer );
}
}
const timer = setInterval( tick, 1000 );
}, [] );
return {
get time() {
return formatTime( count, 'mm:ss' )
},
reset() {
count = initial;
},
get isFinished() {
return count === 0;
},
get count() {
return count;
},
}
}
registerContext( 'example/coundown', ExampleCountdown );
(The save function above doesn't have everything for this, the arguments this component takes still need to be saved and passed with something like the wp-context
directive)
PHP, dynamic blocks
For dynamic blocks, the same serializer could be run as a build-time step instead, and output PHP template strings for the initial values:
Generated for PHP, saved to blocks/example-countdown.php
:
<?php ?>
<div class="wp-block-example-countdown aligncenter is-style-large" wp-context="{'example/countdown': <?= wp_json_encode( $context['example/countdown']['attributes'] ); ?> }">
<div class="<?= $context['example/countdown']['value']['isFinished']; ?>" wp-class:is-finished="example/countdown.isFinished" wp-bind="example/countdown.time"><?= $context['example/countdown']['time']; ?></div>
<button wp-on:click="example/countdown.reset">Reset</button>
</div>
Using it in PHP:
<?php
$context = [
'example/countdown' => [
'attributes' => [
'initial' => 30,
],
'value' => [
'time' => "00:30",
'isFinished' => "",
]
]
];
return render_component_template( 'blocks/example-countdown.php', $context );
?>
Custom directives
The above example maps JSX attributes to directives ( eg onClick
→ wp-on:click
) when there is already an existing practice that makes sense. For other cases, the directives can be written directly:
<button wp-modal-open={ context.id }>Open</button>
Loops, conditionals
For dynamic fragments, helper components could collect the bindings and output the initial markup & template elements.
<Template foreach={ items }>{ item => <li>{ item.label }</li> } </Template>
<Template if={ ! items.count }> <span>No results</span> </Template>
Benefits and limitations
For blocks, this would provide a way to author the frontend component very much like the rest of the block. It also lets the developer reference the context as a real JS object, not as strings.
For static blocks, a compiler is not needed, this can be part of the existing renderToString
function in @wordpress/element
that turns a React element into a HTML string.
As a drawback on the developer experience, it can be hard to explain that the code using the context proxy cannot contain expressions, and they are working with references, not the values of those props. (Though there might be a way with a compiler extension that takes expressions from the JSX and creates context props from them.)
It also probably requires chaining proxies to track deeper level references of the context props.