Four interactive blocks on a product page, each shipping its own approach to the same problem: a carousel written by hand in 2021, a filter bundling a small framework, an accordion written by hand in 2022, and a third-party map widget. A hundred and forty kilobytes, and three independent implementations of event delegation.
The symptom
the page, measured:
block script gzipped approach
carousel 22.4 KB 7.1 bespoke
filter 41.2 KB 12.8 a bundled
framework
accordion 8.1 KB 2.9 bespoke
map 69.0 KB 22.4 third party
────────────────────────────────────────────────
140.7 KB 45.2
and three of the four attach their own document-level
click listener.$ npx lighthouse https://example.test/products/abc --only-categories=performance
Total Blocking Time 410ms
Time to Interactive 3,140ms
# and the case nobody had tested
$ curl -s https://example.test/products/abc | grep -c 'js-filter-form'
1
# the filter renders as a form with no action attribute.
# without JavaScript it does nothing at all.The no-JavaScript case is the finding that changed the priority. Three of the four blocks degraded acceptably and the filter — the one people actually use — rendered a form that submitted nowhere, which had been true since 2022 and had never been tested.
Why it happens
Every block that needs behaviour has to bring its own, because a block theme has no shared client runtime and no convention for one. Four blocks built one at a time over three years produce four answers, and no individual decision was wrong.
The fix
What the API actually is
a directive-based runtime, shipped once by WordPress,
shared by every block that opts in.
directives data-wp-on--click, data-wp-bind--hidden,
data-wp-class--is-open, data-wp-text
a store per namespace, with state, actions and
derived callbacks
server state printed into the page as JSON and
hydrated, so the first render is
correct rather than empty
the runtime 12.8 KB gzipped, loaded once regardless
of how many blocks use it
and it is a Preact signals implementation underneath,
which matters for one thing: reactivity is fine-grained
and does not re-render a component tree.Converting the carousel
<?php
$context = array(
'index' => 0,
'count' => count( $attributes['slides'] ),
);
?>
<div
<?php echo get_block_wrapper_attributes(); // phpcs:ignore ?>
data-wp-interactive="turkerdev/carousel"
<?php echo wp_interactivity_data_wp_context( $context ); // phpcs:ignore ?>
>
<button data-wp-on--click="actions.previous"
data-wp-bind--disabled="state.isFirst">←</button>
<ul data-wp-bind--aria-live="state.liveRegion">
<?php foreach ( $attributes['slides'] as $i => $slide ) : ?>
<li data-wp-bind--hidden="!state.isCurrent"><?php /* ... */ ?></li>
<?php endforeach; ?>
</ul>
</div>
import { store, getContext } from '@wordpress/interactivity'
store('turkerdev/carousel', {
state: {
get isFirst() { return getContext().index === 0 },
get isCurrent() {
const { index } = getContext()
return index === getContext().current
},
},
actions: {
previous() {
const ctx = getContext()
ctx.index = Math.max(0, ctx.index - 1)
},
},
})
bespoke script 22.4 KB (7.1 gzipped)
the store 1.2 KB (0.6 gzipped)
the runtime 12.8 KB gzipped, shared
one block: 7.1 → 13.4. worse.
four blocks: 22.8 → 15.1. better.
which means the decision is about the page, not about
the block.Converting one block is a loss and converting the set is a substantial win, because the runtime is a fixed cost amortised across everything that uses it. That is the arithmetic to do before starting, and it is the reason a gradual conversion looks like a regression for the first two blocks.
The filter, where the server rendering had to change
<form
method="get"
action="<?php echo esc_url( get_permalink() ); ?>"
data-wp-interactive="turkerdev/filter"
data-wp-on--submit="actions.filter"
>
<select name="status" data-wp-on--change="actions.filter">
<?php /* options, with the current one selected server-side */ ?>
</select>
<noscript><button type="submit">Filter</button></noscript>
</form>
A real form with a real action, enhanced by a directive that intercepts the submit — which is the shape progressive enhancement always had and which the bespoke version had abandoned because it was easier to render an empty div and fill it. The noscript button is the only markup that exists solely for the degraded case.
// the server rendering now has to handle the filtered
// state, which it previously never did
$query = new WP_Query( array(
'post_type' => 'product',
'meta_query' => $this->metaQueryFrom( $_GET ),
'paged' => max( 1, (int) ( $_GET['paged'] ?? 1 ) ),
) );
// which is 40 lines that did not exist, and is the
// actual cost of this conversion.
Forty lines of server-side query handling is the real work in this conversion, and it is work that should have existed from the start — the block had been rendering an empty container and letting the client fill it, which is why it did nothing without scripting.
The block that could not be converted
the map is a third-party widget: 69 KB, its own
rendering, its own event model, its own tile loading.
nothing about directives helps. the runtime cannot wrap
a library that owns its own DOM subtree.
what we did instead:
loaded it on interaction rather than on page load —
a placeholder with the static image, and the widget
fetched when it is clicked or scrolled into view.
69 KB on every page load → 69 KB for the 22% who
interact with it.
which is a bigger saving than the other three combined.The largest win came from the block we could not convert, by not loading it, which is the ordinary shape of a performance exercise. Twenty-two per cent interaction rate came from an existing analytics event and was the number that made the decision obvious.
Hydration, and the flash we introduced
the accordion rendered every panel open for about 200ms
before the runtime hydrated and closed them.
why: the server rendered the panels visible, and
data-wp-bind--hidden applied on hydration.
the fix: render the correct initial state server-side
and let the directive maintain it —
<div <?php echo $i === 0 ? '' : 'hidden'; ?>
data-wp-bind--hidden="!state.isOpen">
which is the general rule: the server must render what
the client would render, or there is a flash. the
directives do not remove that obligation.The interactivity router, which we did not use
6.5 also ships an experimental client-side navigation
module that intercepts links and swaps regions.
we read it and did not adopt it:
it is marked experimental and the API is expected
to change
the page it would help most is the filtered product
list, which is now a full page load in 180ms
a client-side router owns history, focus management
and announcement, and getting those wrong is an
accessibility regression rather than a bug
revisit when it is stable.Verifying it worked
$ ./bin/page-weight https://example.test/products/abc
javascript, initial: 18.1 KB gzipped # was 45.2
javascript, on map
interaction: 22.4 KB # deferred
$ npx lighthouse ... --only-categories=performance
Total Blocking Time 88ms # was 410ms
Time to Interactive 1,210ms # was 3,140ms
$ npx playwright test --grep 'without JavaScript'
✓ the filter form submits and returns filtered results
✓ the accordion renders all panels open and readable
✓ the carousel renders every slide in document order
$ ./bin/axe-scan https://example.test/products/abc
0 violations # was 2 (the carousel's
# live region, now correct)The no-JavaScript tests are the ones worth having, because they assert the property that was silently false for two years. The accessibility scan improving was not a goal — the live region binding is part of the directive markup rather than something the bespoke script had remembered to do.
What this costs
A runtime that is WordPress’s rather than ours, upgraded on WordPress’s schedule, with an API that is young enough to move. A breaking change to a directive name is a change to markup in PHP templates across four blocks, and there is no type checking anywhere in that path.
The server-side rendering obligation is the larger ongoing cost. Every interactive block now has to render its correct initial state in PHP and maintain it in directives, which is two implementations of the same state machine that must agree — and the failure mode is a flash of wrong content that only appears on a slow connection.
It is also a bet on an API that WordPress could deprecate. The blocks are not portable outside it in a way the bespoke scripts were, and the honest position is that this trades independence for a shared runtime — which is the correct trade at four blocks and would not be at one.