Interactivity, and the JavaScript we stopped shipping

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.
<?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">&larr;</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.