Skip to content
This repository has been archived by the owner on Jul 28, 2023. It is now read-only.

Proposal: A JSX layer for directives #94

Closed
yscik opened this issue Oct 26, 2022 · 6 comments
Closed

Proposal: A JSX layer for directives #94

yscik opened this issue Oct 26, 2022 · 6 comments

Comments

@yscik
Copy link
Collaborator

yscik commented Oct 26, 2022

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:

  1. 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 ) };
	},
} ) )
  1. 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 onClickwp-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.

@luisherranz
Copy link
Member

Wow, thanks a lot, Peter. This is very interesting 🙂

From your comment, I understand that you'd like to:

  • Avoid the usage of strings to reference state/context and actions/effects.
  • Avoid the usage of wp-xxx directives and use regular JSX syntax instead.

So basically, write something like this:

<div className={{"is-finished": isFinished}}>
<button onClick={reset}>

Instead of something like this:

<div wp-class:is-finished="context.isFinished">
<button wp-on:click="actions.reset">

Is that the case? Do you have in mind any other things apart from those?


For reference, this is how this exact block would be written using the current directives+strings syntax:

// save.js
import { formatTime } from "external-library";

export const save = ({ attributes: { initial } }) => (
  <div
    {...useBlockProps.save()}
    wp-context={`{"initial": ${initial}`}
    wp-init="actions.countdown.init"
  >
    <div wp-class:is-finished="context.isFinished" wp-text="context.time">
      {formatTime(initial, "mm:ss")}
    </div>
    <button wp-on:click="actions.countdown.reset">
      {__("Reset", "text-domain")}
    </button>
  </div>
);

// view.js (frontend)
import { formatTime } from "external-library";

wpx({
  actions: {
    countdown: {
      reset: ({ context }) => {
        context.count = context.initial;
      },
      init: ({ context }) => {
        context.count = context.initial;
        context.time = ({ context }) => formatTime(context.count, "mm:ss");
        context.isFinished = ({ context }) => context.count === 0;

        const timer = setInterval(() => {
          context.count--;
          if (context.count === 0) {
            clearInterval(timer);
          }
        }, 1000);
      },
    },
  },
});
@yscik
Copy link
Collaborator Author

yscik commented Oct 27, 2022

Yes, it was mainly the first point I was looking to resolve, and more generally, to bring the logic/context and the markup closer to each other.

Having wp-xxx directives in there is fine, the mapping of existing JSX attributes to directives is just a nice opportunity that makes things more familiar. And it could also make components easier to share.

Another thing that's less explicit in the example, is a private context. (Or maybe context is not even a good name in that case.) I think most of the time a component would only want to deal with what it declared, and it could be worth optimizing for that case. Particularly painful was passing a merged context object to every action, and having to extract its own context from there. Instead accessing parent context (or other contexts defined on for the same node/component) could have an API, similar to useContext in React, like useContext( 'example/form' ).

@luisherranz
Copy link
Member

Great, thanks 🙂

I think this is worth investigating, so I have created a Stackblitz with your example but using the current syntax. It'd be great if you could fork it to add a basic version of the pattern you mentioned in the opening post. I'd love to see how it would play out in practice. Then, we can start shaping the DX to see if we can come up with a great solution 🙂

I've recorded a video to explain how the Stackblitz example is structured:

https://www.loom.com/share/3880dbd776b348a6b122b16efe89568b

Even though I'm asking Peter, if anyone else is reading this, feel free to play with these examples/ideas and add your feedback/comments as well.

@michalczaplinski
Copy link
Collaborator

I just only got a chance to take a deeper look at this and it's a fantastic idea, Peter! We should probably give it a fair shot at implementing once we are working on implementing the directives into GB.

For static blocks, this should be totally doable. I think it would be somewhat similar to how context is implemented in Mitosis: BuilderIO/mitosis#113 because both in their framework and in ours, the context has be static i.e. serializable.

For dynamic blocks, the story is a bit more complex so I would be very keen to see if we could improve it a little bit. It seems to me that the DX would be a little worse when doing this:

<?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>

rather than using the Alpine-style syntax. But I'd be very keen to figure out if we can improve it.

@luisherranz
Copy link
Member

For static blocks, this should be totally doable. I think it would be somewhat similar to how context is implemented in Mitosis: BuilderIO/mitosis#113 because both in their framework and in ours, the context has be static i.e. serializable.

Would you mind modifying the Stackblitz example I shared to show what the API would look like? 🙂

@yscik
Copy link
Collaborator Author

yscik commented Nov 25, 2022

Dynamic blocks DX

<div 
    class="wp-block-example-countdown aligncenter is-style-large" 
    wp-context="{'example/countdown': <?= wp_json_encode( $context['example/countdown']['attributes'] ); ?> }">

Note that this PHP code would be generated (by the JS build tooling), as an option to author dynamic blocks in JSX.
I initially thought that, like the JSX → HTML serializer, this build tool can use the same approach, but generate PHP expressions in place of context values. But that does mean executing the save function in a node script, not the browser, which comes with some limitations. Though the save function should be pure anyway, so maybe it's not that big of a deal.

But if everything works, <div>{ time }</div> would turn into:

  • Static block → <div wp-bind="example/countdown.time">00:30</div>
    (in the editor, when saving the post)
  • Dynamic block → <div wp-bind="example/countdown.time"><?= $context['example/countdown']['time']; ?></div>
    (in a build script)
@WordPress WordPress locked and limited conversation to collaborators Jul 27, 2023
@luisherranz luisherranz converted this issue into a discussion Jul 27, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
3 participants