Categories
Engineering Spotlight

How does WooCommerce Blocks render interactive blocks in the frontend?

As Gutenberg is starting to experiment with several approaches to render interactive blocks on the front end, for example using React, we wanted to share how this is currently done in WooCommerce Blocks. Our approach is not the only way of doing it, and it will probably evolve once APIs with the same purpose are added upstream to Gutenberg, however, it’s a system that proved to be solid and is giving good results in real life stores.

The gist of it

  • We build our block as a React component that isn’t aware of Gutenberg. That block accepts a prop that is used to pass block attributes.
  • That React component is rendered in the editor, with the addition of some controls to set the block attributes.
  • When the post or page is saved, only an empty div element with an ID or class is saved. Below is an example:
<!-- wp:woocommerce/product-title -->
<div class="wp-block-woocommerce-product-title is-loading"></div>
<!-- /wp:woocommerce/product-title -->
  • In the PHP render_callback, we append block attributes as data- HTML attributes. So this is what ends up being rendered in the page:
<div data-block-name="woocommerce/product-title" class="is-loading wp-block-woocommerce-product-title"></div>
  • On the front end, we just need to render the React component as you would render any other React app: render(BlockComponent, divElement). We read data- HTML attributes and pass them as props to the component.

Now going into details…

File structure

We’re going to take the structure that comes with @wordpress/create-block and add a few things to it.

We created two new files, block.js and frontend.js. One will contain our shared component, and the other will render it on the front end.

// ./src/block.js
// Block is just a simple example of how to use attributes.
export default function Block( { color, headingSize = 1 } ) {
	const HeadingTag = `h${ headingSize }`;
	return (
		<div className="wc-interactive-block" style={ { color } }>
			<HeadingTag>Your Magic here</HeadingTag>
		</div>
	);
}

We change our edit.js function to be like this:

// ./src/edit.js
// Edit can have InspectorControl and all other stuff, no limits.
export default function Edit( { attributes, setAttributes } ) {
	const { headingSize } = attributes;
	return (
		<div { ...useBlockProps() }>
			<BlockControls>
				<HeadingToolbar
					isCollapsed={ true }
					minLevel={ 1 }
					maxLevel={ 7 }
					selectedLevel={ headingSize }
					onChange={ ( newLevel ) =>
						setAttributes( { headingSize: newLevel } )
					}
				/>
			</BlockControls>
			<Block { ...attributes } />
		</div>
	);
}

And the save.js function to this:

export default function save() {
	return <div { ...useBlockProps.save() } />;
}

We do this because we can’t save a React component to HTML. We’re just saving a div element that we will use as the root element later. We avoid saving attributes there as data because they will get stripped on saving if a non-admin saved the page.

Our frontend.js would be like this:

// ./src/frontend.js
import { render, Suspense } from '@wordpress/element';
import Block from './block';

window.addEventListener( 'DOMContentLoaded', () => {
	const element = document.querySelector(
		'.wp-block-create-block-interactive-block'
	);
	if ( element ) {
		const attributes = { ...element.dataset };
		render(
			<Suspense fallback={ <div className="wp-block-placeholder" /> }>
				<Block { ...attributes } />
			</Suspense>,
			element
		);
	}
} );

In the code above, we’re:

  • Getting the div we saved to element.
  • Taking the attributes from it (that we will append in a moment using PHP).
  • Rendering the block using render.

Our Webpack configuration will change as well. We need to create a new webpack.config.js at the project root with this:

// ./webpack.config.js
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );

module.exports = {
	...defaultConfig,
	entry: {
		...defaultConfig.entry,
		frontend: './src/frontend.js',
	},
};

Running npm run build will produce our files!

PHP changes needed

We need to do two things:

  • Enqueue our frontend.js script only when that block is rendered.
  • Append attributes to what’s going to be rendered.
function create_block_interactive_block_block_init() {
	register_block_type( __DIR__, array(
		'render_callback' => 'render_block_with_attribures'
	) );
}

// Copied from @wordpress/dependency-extraction-webpack-plugin docs.
function enqueue_frontend_script() {
	$script_path       = 'build/frontend.js';
	$script_asset_path = 'build/frontend.asset.php';
	$script_asset      = require( $script_asset_path );
	$script_url = plugins_url( $script_path, __FILE__ );
	wp_enqueue_script( 'script', $script_url, $script_asset['dependencies'], $script_asset['version'] );
}

// Copied from WooCommerce Blocks.
function add_attributes_to_block( $whitelisted_blocks ) {
	$whitelisted_blocks[] = 'create-block/interactive-block';
	return $whitelisted_blocks;
}

function render_block_with_attribures( $attributes = [], $content = '' ) {
	if ( ! is_admin() ) {
		enqueue_frontend_script();
	}
	return $content;
};

add_action( 'init', 'create_block_interactive_block_block_init' );
add_action( '__experimental_woocommerce_blocks_add_data_attributes_to_block', 'add_attributes_to_block' );

__experimental_woocommerce_blocks_add_data_attributes_to_block handles adding attributes for you, if you want to handle that yourself, you can use the following code instead:

// Copied from WooCommerce Blocks.
function add_attributes_to_block( $attributes = [], $content = '' ) {
	$escaped_data_attributes = [];

	foreach ( $attributes as $key => $value ) {
		if ( is_bool( $value ) ) {
			$value = $value ? 'true' : 'false';
		}
		if ( ! is_scalar( $value ) ) {
			$value = wp_json_encode( $value );
		}
		$escaped_data_attributes[] = 'data-' . esc_attr( strtolower( preg_replace( '/(?<!\ )[A-Z]/', '-$0', $key ) ) ) . '="' . esc_attr( $value ) . '"';
	}

	return preg_replace( '/^<div /', '<div ' . implode( ' ', $escaped_data_attributes ) . ' ', trim( $content ) );
}

function render_block_with_attribures( $attributes = [], $content = '' ) {
	if ( ! is_admin() ) {
		enqueue_frontend_script();
	}
	return add_attributes_to_block($attributes, $content);
};

Things to watch out for

When building your front-end-based components, make sure you’re not consuming anything editor-specific, so components from @wordpress/block-editor or @wordpress/blocks shouldn’t be used.

To make sure your final frontend.js isn’t leaking extra large dependencies to the front end, you check the assets file, frontend.assets.php.

This file, for example, would only load wp-element and wp-polyfill on the front end:

Common Questions

Why do we need add_attributes_to_block?

That function does two things:

  • It appends your attributes to the block div, this is because you can’t save data- attributes in the save function if you’re a non admin.
  • It handles the correct types.

What if I need more interactivity in the editor?

With this approach, you can add more interactivity in the editor, like using RichText to allow users to introduce text, adding some events, or anything. In order to do so, you can break your Block component into smaller chunks and use them inside the edit function.

An example of this is the Filter Products by Price block, where the block heading is replaced by a component with the Gutenberg Block PlainText, which allows directly editing the heading in the editor.

I need an innerBlocks structure

This is more complicated, but still possible. You can take a look at Checkout block included in WooCommerce Blocks 6.0.0, and the future iteration of the Cart block, currently only available in the development builds.

We used a similar approach in the past for the All Products and Single Product blocks (the latter, only available in dev builds). However, we recommend using the Cart and Checkout blocks as the reference, given that their approach is more up to date.

Can I lazy load things

You’re already lazy loading things, but if you want to take your code a step further, you can lazy load your component in frontend.js

import { render, Suspense, lazy } from '@wordpress/element';

// modify webpack publicPath at runtime based on the location of your scripts.
// eslint-disable-next-line camelcase, no-undef
__webpack_public_path__ =
	'https://example/wp-content/plugins/interactive-block/build/';
const Block = lazy( () =>
	/* webpackChunkName: "lazy-loaded-blocks/interactive-blocks" */ import(
		'./block'
	)
);

window.addEventListener( 'DOMContentLoaded', () => {
	const element = document.querySelector(
		'.wp-block-create-block-interactive-block'
	);
	if ( element ) {
		const attributes = { ...element.dataset };
		render(
			<Suspense fallback={ <div className="wp-block-placeholder" /> }>
				<Block { ...attributes } />
			</Suspense>,
			element
		);
	}
} );

Assign __webpack_public_path__ to your plugin build path. We pass that value from the server.

How about lazy loading the block and its dependencies?

We are currently experimenting with a more aggressive lazy-loading approach for the Mini Cart block that would allow lazy-loading all dependencies. The way it works is as follows:

  • Only a small JavaScript file is downloaded & parsed on each page where the Mini Cart block is present. That script is used to detect mouse hover/focus on the mini cart button, listen to add_to_cart events and to preload + append the other scripts.
  • Once the page is loaded, all other scripts are preloaded. That means they don’t block rendering the page and, instead, they are downloaded in the background after the page is loaded.
  • Once the user interacts with the mini cart or adds a product to the cart, those scripts are appended to the page and, because of that, parsed and executed. The Mini Cart block is now completely interactive!

How about outside WordPress?

This is more complicated, but the block has nothing to do with it. Just the build part, code sharing aspect, and loading things.

Code

You can see the whole code for this demo in this GitHub repo: https://github.com/senadir/interactive-block.

6 replies on “How does WooCommerce Blocks render interactive blocks in the frontend?”

The WordPress performance team initiative is something we’re extremely excited for and will be watching for any developments that come at the core platform level that we can make the best use of and implement.

The approach above suggests ways to load and share data with dynamic blocks in the frontend, but a lot of implementation detail depend on the plugin/theme using this. If, for example, you have issues using React on the frontend, you can use Preact instead, this is something that the Jetpack search team did. If you’re concerned about loading any JavaScript on the frontend, then this approach might not be the best for you. You can possibly start with vanilla JavaScript and then load your React components later, this is something that WooCommerce Mini Cart block uses, but is complicated to implement, so we didn’t mention it in depth in this article.
Performance is an aspect that is always kept in consideration in our work at WooCommerce. We use a mix of lazy loading scripts, loading only when needed, and keep an eye on our built files. As a collection of blocks, WooCommerce blocks affects little into the general performance of a store, other factors, such as hosting, caching, other plugins, and theme play a role into that.
We’re also limited by the platform we’re serving code from, things like loading scripts on demand, deferring them until a page finishes loading, cannot yet be achieved with the tools that WordPress provides.

Liked by 2 people

I can’t get the trend 🥺

Am I the only one seeing overcomplicated boilerplate code for trivial examples with Gutenberg?

For me, there is something fundamental architectural wrong, if I, as a developer have to deep dive into React in the future, where WordPress ist easy now.

Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.