Skip to content

Add support for dynamically injected CSS #76031

@markusfoo

Description

@markusfoo

Component: @wordpress/interactivity-router
Affects: WordPress 6.5+ with Interactivity API router enabled
Tested against: WordPress 7.0 Beta 2, @wordpress/interactivity-router bundled in core
Related: #75778


Summary

The navigate() function performs a head-merge diff on every SPA navigation. Any <link rel="stylesheet"> or <style> element that was dynamically injected into <head> by JavaScript after the initial page load stops working after the first navigation.

The exact symptom is important and easy to misdiagnose. The <link> node is not always physically removed from the DOM tree. In DevTools Element inspector, document.querySelector('#my-runtime-style') may return the element — it's there. But in DevTools → Sources → Page, the stylesheet file disappears from the list of loaded resources. The CSS rules stop applying. The element is present in the DOM but the browser's style engine has lost its reference to it — the link is effectively severed from the resource pipeline.

This distinction matters because a developer checking the DOM inspector will see the element and conclude the issue is in their CSS, not in the router's lifecycle management.

This silently breaks a broad category of legitimate, widely-used patterns:

  • Runtime theme and dark-mode switchers
  • Third-party plugins that inject user-defined CSS (Complianz GDPR, accessibility overlays)
  • Lazy-loaded stylesheets triggered by user interaction
  • A/B testing stylesheets
  • Any plugin that calls document.head.appendChild(link) as part of its normal operation

None of these patterns produce JavaScript errors or console warnings. The stylesheet simply stops applying after navigation, with no indication of why.


Steps to Reproduce

Minimal reproduction

  1. On any page that uses data-wp-router-region, inject a stylesheet from JavaScript after page load:
const link = document.createElement( 'link' );
link.rel  = 'stylesheet';
link.id   = 'my-runtime-style';
link.href = '/wp-content/themes/mytheme/css/optional.css';
document.head.appendChild( link );
  1. Confirm the stylesheet is active:

    • DevTools → Elements: <link id="my-runtime-style"> is visible in <head>
    • DevTools → Sources → Page: the stylesheet file appears in the resource list
    • The CSS rules from the file are applied to the document
  2. Navigate to any internal page via the iAPI router.

  3. Inspect the results:

    • DevTools → Elements: The <link id="my-runtime-style"> node may still appear in <head>this is the misleading part
    • DevTools → Sources → Page: The stylesheet file is gone from the loaded resources list
    • Applied styles: The CSS rules from the file are no longer applied to anything
  4. Navigate again — the stylesheet remains deactivated. It is never restored on subsequent navigations.

The key diagnostic signal is not the DOM inspector but the resource loader. The element node persists; the live stylesheet connection does not.

Real-world reproduction — Complianz GDPR plugin

  1. Install Complianz GDPR on a site using the iAPI router.
  2. Apply a custom style to the consent banner via Complianz's built-in CSS editor. Complianz injects this as a <style> or <link> element dynamically via JavaScript after DOM ready.
  3. Load the site — the banner renders with the custom style correctly.
  4. Navigate to another page via the iAPI router.
  5. The custom style stops applying. The <link> node may still appear in the DOM inspector, but the stylesheet is absent from DevTools → Sources → Page, and the banner loses all custom styling on every subsequent page.

Reproducible on any plugin that uses the standard JavaScript pattern of injecting styles after DOM ready.


Root Cause

The router's head-merge algorithm compares the current <head> DOM against the fetched page's PHP-rendered <head>. When it reconciles the two, dynamically-injected elements — which were never part of any PHP-rendered <head> — are absent from every fetched page's source. The router's merge process either removes them outright or leaves them as stale DOM nodes disconnected from the browser's active resource pipeline.

The result is the same either way: the browser stops treating the element as a live stylesheet. The file disappears from Sources → Page. The CSS stops applying.

Since dynamically-injected elements were never part of any server-rendered <head>, they are absent from every fetched page. The merge removes or disconnects them on every navigation, unconditionally.

This is architecturally correct for PHP-enqueued assets that change between pages. It is a problem for client-side runtime state that the server has no knowledge of.


What the Documentation Does Not Address

After reviewing the (https://developer.wordpress.org/block-editor/reference-guides/interactivity-api/), there is a relevant gap worth noting.

The router's head-merge behavior is not documented. There is a section on Server-side rendering, a section on Directives, and general statements about client-side navigation being "fast and seamless" — but there is no documentation of what navigate() does to <head> during navigation, which elements survive, which do not, and why. The router's lifecycle is treated as an internal implementation detail.

This creates a specific problem: the documentation teaches developers to think in terms of state, directives, and reactive callbacks. It does not prepare them for the fact that navigate() will silently invalidate any stylesheet they inject after the initial load. There is no warning, no escape hatch described, and no documented pattern for managing client-side head assets across navigations.

The documentation also states under API Goals:

Extensible: In the same way WordPress focuses on extensibility, this new system must provide extensibility patterns to cover most use cases.

And under the Server-side rendering guide:

Server-rendered HTML and client-hydrated HTML must be exactly the same.

The second principle is the root of this issue. The router is built around the assumption that the server is the permanent source of truth for <head> content. This is a reasonable assumption for PHP-enqueued assets. It breaks for any feature that modifies <head> based on user interaction — precisely the kind of feature the "declarative and reactive" model is meant to enable.

The extensibility goal and the SSR-first principle are in direct tension here, and the router currently resolves that tension entirely in favor of SSR with no mechanism for developers to opt out.


Why Existing Patterns Do Not Solve This

data-wp-watch + document.head.appendChild()

The callback fires after navigation, but the head-merge has already run and deactivated the element. Re-injecting inside the callback creates a brief gap on every navigation where styles are absent, and the newly injected element is deactivated again on the next navigation.

data-wp-bind--href on a PHP-enqueued <link>

This is the closest to a working approach: PHP unconditionally enqueues a <link> with a placeholder href, and JavaScript updates the href via data-wp-bind. The element survives the head-merge because it was present in the PHP-rendered source.

However, this approach requires the PHP theme to know about every possible runtime stylesheet in advance. It cannot work for third-party plugins (Complianz, accessibility tools, etc.) that manage their own dynamic styles without PHP-side awareness of the router. It is also not documented as a supported pattern anywhere in the official documentation.

Server-side persistence (cookies, user meta)

Store the preference server-side so PHP outputs the correct <link> on every render. This works in principle but contradicts the premise of SPA navigation: it requires a write operation for every client-side preference change, a REST endpoint, nonce management, and special handling for guest users. It turns a trivial client-side interaction into a full server round-trip.

Injecting into <body> instead of <head>

Identical behavior regardless of injection target. A dynamically-injected <link> placed anywhere in the document outside a router region is deactivated after navigation. This is confirmed on Chrome, Firefox, and Safari.

The only working approach

Enqueue all possible stylesheets unconditionally via PHP and switch themes by toggling a CSS class on <body>:

// PHP — enqueue all variants unconditionally so the router preserves them
wp_enqueue_style( 'theme-light', get_template_directory_uri() . '/css/theme-light.css' );
wp_enqueue_style( 'theme-dark',  get_template_directory_uri() . '/css/theme-dark.css' );
// JS — switch by toggling a body class, not by swapping <link> elements
document.body.classList.toggle( 'theme-dark', isDark );

This works because both stylesheets are present in every PHP-rendered <head>, so the router preserves them. The <body> element itself is never replaced by the router, so the class survives navigation.

The drawbacks are significant: all stylesheet variants load on every page regardless of user preference, increasing page weight. For third-party plugins that manage their own dynamic styles, this approach is impossible — they cannot modify PHP enqueue logic at theme level.


Impact

Plugin ecosystem

Any plugin that injects styles dynamically — which describes a substantial portion of the WordPress plugin ecosystem — is silently broken when a site enables the iAPI router. The breakage is uniquely difficult to diagnose: no JavaScript errors, no 404s, the DOM node may still be visible in the inspector. Only checking the DevTools resource list reveals the stylesheet has been deactivated. Most developers will not find the cause without knowing about the head-merge behavior, which is not documented.

Theme development

The standard JavaScript pattern for runtime CSS features — document.head.appendChild(link) — has worked reliably in WordPress for well over a decade. Theme developers implementing dark mode, font size controls, or accessibility overlays using this pattern will find their features stop working the moment the site uses the iAPI router, with no diagnostic information in the console.

Architectural tension

The router currently treats <head> as entirely server-owned. For pure block themes this is fine. For hybrid themes, classic themes adopting the router incrementally, and the vast majority of existing plugins, this creates a category of silent failures that cannot be worked around without either server-side infrastructure or loading all possible stylesheets unconditionally.


Suggested Solutions

Option A — data-wp-head-persistent attribute (minimal change, preferred)

Add a marker attribute that the head-merge algorithm recognizes as "preserve this element regardless of whether it appears in the fetched page":

<link id="runtime-theme" rel="stylesheet" href="" data-wp-head-persistent>

The router skips any processing for elements carrying this attribute. Developers and plugins can set it when injecting:

link.setAttribute( 'data-wp-head-persistent', '' );
document.head.appendChild( link );

No new API surface, fully backward-compatible, resolves the majority of real-world cases with minimal implementation cost.

Option B — headAssets API in the router

Expose a managed registry for persistent head assets as part of the navigation lifecycle:

import { headAssets } from '@wordpress/interactivity-router';

store( 'myTheme', {
    actions: {
        setTheme( href ) {
            headAssets.setLink( 'theme-override', href );
        },
    },
} );

The router re-applies registered assets after every navigation, treating them as client-side state that is separate from the PHP-rendered head.

Option C — Router-aware data-wp-bind--href on <link> elements in <head>

Extend directive support to <link> elements in <head>, with the router preserving bound elements and re-evaluating their directives after each navigation — consistent with how directives work in body regions today.


Environment

  • WordPress 7.0 Beta 2 / 6.9+ etc
  • @wordpress/interactivity-router bundled in core
  • Classic (non-block) theme with iAPI router enabled via wp_enqueue_script_module
  • WooCommerce 10.7 Dev / 10.6 Beta
  • Reproduced on Chrome, Firefox, Safari iOS etc,..

Discovered while implementing a multi-theme switcher on a WooCommerce storefront using the iAPI router for SPA navigation. The exact failure mode — element visible in the DOM inspector but absent from DevTools resource list — is what makes this bug particularly difficult to diagnose without knowledge of the router's head-merge behavior, which is not covered in the official documentation.

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions