Skip to content

Fix: interactivity-router preserve dynamically-injected and deferred stylesheets across navigations#76289

Open
markusfoo wants to merge 171 commits into
WordPress:trunkfrom
markusfoo:fix/spa-navigate-deactivate-dynamic-styles
Open

Fix: interactivity-router preserve dynamically-injected and deferred stylesheets across navigations#76289
markusfoo wants to merge 171 commits into
WordPress:trunkfrom
markusfoo:fix/spa-navigate-deactivate-dynamic-styles

Conversation

@markusfoo
Copy link
Copy Markdown

@markusfoo markusfoo commented Mar 7, 2026

Closes #76031
Related: Interactivity API: Roadmap #52904

Co-authored-by: DAreRodz darerodz@git.wordpress.org
Co-authored-by: luisherranz luisherranz@git.wordpress.org

CC @DAreRodz @luisherranz


The problem

packages/interactivity-router/src/assets/styles.ts silently sets sheet.disabled = true on stylesheets it should not own. No console errors. No 404s. The <link> nodes stay in the DOM and look fine in the Elements panel — the only diagnostic signal is DevTools → Sources → Page, where the file disappears from loaded resources.

This surfaces in two related ways, both caused by the same overly aggressive disable logic in applyStyles():

A — runtime-activated deferred stylesheets reset on navigation.
wp_enqueue_style() with a media parameter is a standard pattern for conditional stylesheets. A store activates such a sheet by mutating link.media. On the next navigate() the SCS algorithm compares the live element (media="all") against the server-returned element (media="not all") — isEqualNode() treats them as different nodes, drops the live one from page.styles, and applyStyles() disables it. The user's theme resets silently on every navigation.

B — dynamically-injected plugin stylesheets disabled on navigation.
Plugins like Complianz GDPR append <link> elements via document.head.appendChild(), bypassing wp_enqueue_style(). These elements are absent from every server-rendered response and never appear in page.styles. The unconditional else { el.sheet.disabled = true } in applyStyles() catches them on the first navigate() — consent banner CSS, accessibility overlays, anything injected outside WP's enqueue system stops working silently.


The fix

Bug A — one line in normalizeMedia(), which already runs on both sides before SCS comparison:

- } else if ( ! element.media ) {
+ } else if ( ! element.media || element.media === 'not all' ) {

Both the live element (media="all") and the server element (media="not all") now normalise to "all" before isEqualNode() sees them — they match. areNodesEqual() is back to plain isEqualNode(). media="print" and media="screen" normalise to themselves — no collapse.

Bug B — a module-level routerManagedStyles Set seeded from elements with an id attribute:

+ const routerManagedStyles = new Set(
+     document.querySelectorAll( 'link[rel=stylesheet][id], style[id]' )
+ );

wp_enqueue_style() has generated id="{handle}-css" since WordPress 2.6. Plugin-injected elements never carry one. applyStyles() now only disables enrolled elements:

- } else {
-     el.sheet.disabled = true;
+ } else if ( routerManagedStyles.has( el ) ) {
+     el.sheet.disabled = true;

Elements enrolled on first activation out of media="preload" state — the only path WP-managed sheets follow. Plugin elements never enter it.

Developer guideline: assign a unique id to any dynamically injected <style> or <link>. This is already the WordPress convention (id="{handle}-css") and makes the element's relationship to the router explicit.


Stylesheets vs scripts

Scripts executing out of order can corrupt state, fire unintended side effects, break the reactive model — strict lifecycle management is correct for scripts.

Stylesheets only affect visual rendering. There is no scenario where an unmanaged stylesheet surviving a navigation breaks router mechanics, reactive state, or block editor behaviour. The previous behaviour — disabling everything not in the server-rendered snapshot — applied script-level strictness to something with zero script-level risk. The new model: manage only what the router explicitly loaded, leave everything else untouched.


Verified on a live site

markuss.cu.ma/blog/ — WooCommerce + Complianz GDPR + iAPI theme switcher (store(), state, actions, data-wp-on--click). No document.head.appendChild() in theme code. Both bugs reproduce naturally.

Before: navigating from blog index to any post disabled the Complianz consent banner CSS and reset the active theme variant.
After: both survive every navigation path including A→B→C→A.

Before

Video_260308000952.mp4

After

Video_260308001244.mp4

Diagram

fixbugstyles


Files changed

File Change
packages/interactivity-router/src/assets/styles.ts Changed — normalizeMedia, routerManagedStyles, applyStyles
packages/interactivity-router/src/assets/test/styles.test.ts Changed — unit tests for both bugs
test/e2e/specs/interactivity/router-dynamic-styles.spec.ts Added — E2E spec: both bugs + back-navigation
test/e2e/specs/interactivity/plugins/interactive-blocks/router-dynamic-style/* Added — test block fixture

…ated stylesheets across navigations

Fixes two silent failures in applyStyles() on every navigate() call:

1. Dynamic injections (Complianz, accessibility overlays, etc.) had sheet.disabled = true set because they were never in page.styles. Fix: seed routerManagedStyles at module init from id-bearing elements only. wp_enqueue_style() always produces id="{handle}-css"; elements without id are never enrolled and never disabled.

2. PHP-enqueued media="not all" stylesheets activated at runtime via link.media = "all" (theme-switcher pattern) were treated as different elements by isEqualNode, causing the SCS to insert the server copy and disable the client-mutated one. Fix: strip the media attribute from clones before isEqualNode in areNodesEqual so both sides match regardless of runtime media state.

Fixes WordPress#76031. Ref WordPress#52904.
@github-actions github-actions Bot added the [Package] Interactivity Router /packages/interactivity-router label Mar 7, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 7, 2026

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Unlinked Accounts

The following contributors have not linked their GitHub and WordPress.org accounts: @manager1@onmail.com, @greenSkin.

Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Unlinked contributors: manager1@onmail.com, greenSkin.

Co-authored-by: markusfoo <power2009@git.wordpress.org>
Co-authored-by: DAreRodz <darerodz@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@markusfoo markusfoo changed the title Fix: interactivity-router: preserve dynamically-injected and deferred stylesheets across navigations Fix: interactivity-router preserve dynamically-injected and deferred stylesheets across navigations Mar 7, 2026
Marcus Karlos (S.K) added 6 commits March 8, 2026 01:41
…WordPress#76289)

Three new test cases covering the two bugs fixed in the previous commit.

areNodesEqual — media mismatch treated as equal:
Verifies that a <link> with media="all" and a <link> with media="not all" but the same href and integrity are treated as the same element by the SCS comparator, and that the live DOM element is reused rather than replaced.

areNodesEqual — integrity difference still discriminates:
Confirms that other attributes (integrity) still differentiate elements, ensuring the media-stripping does not over-broaden equality.

applyStyles — dynamically-injected plugin styles never disabled:
Verifies that a <link> without an id attribute, injected via appendChild(), is never disabled on any navigation cycle.

applyStyles — plugin styles not enrolled on A→B→C→A back-navigation:
Verifies that, on return to a cached page whose page.styles snapshot includes the plugin element, applyStyles() does not enroll the plugin element in routerManagedStyles and does not disable it on the subsequent navigation away.

Also updates the existing "should enable included styles and disable others" test to reflect the new enrollment-on-activation model: elements must pass through media="preload" activation before they become candidates for disabling.
Three Playwright tests covering real-browser behavior across all
navigation paths, registered via the test/router-dynamic-styles block.

- runtime-activated deferred stylesheet survives forward navigation
(Bug A: media="not all" → "all" preserved after navigate())

- plugin-injected stylesheet survives forward navigation 
(Bug B: no-id element stays active after navigate())

- plugin-injected stylesheet survives back-navigation A→B→C→A
(Bug B cache regression: cached page.styles may include the plugin element; verifies it is not enrolled and not disabled on re-visit)
Reformat seven expect() chains so the argument sits inline with expect() and the assertion value moves to the next line, matching prettier's preferred style for chained matchers.

No logic change.
Add block.json to register the test/router-dynamic-styles block used by router-dynamic-styles.spec.ts E2E fixtures. Declares viewScriptModule pointing to view.js and render callback pointing to render.php.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 8, 2026

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Interactivity Router.

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

Marcus Karlos (S.K) added 16 commits March 8, 2026 04:07
…block

Add deferred-style.css enqueued by render.php with media="not all".

Sets --test-deferred-active: 1 on body so the view script can detect activation state via getComputedStyle without relying on visual inspection. The property is only visible when the sheet is both active (media matches) and not disabled — making it a reliable signal for the Bug A fixture in router-dynamic-styles.spec.ts.
Add view.js with the @wordpress/interactivity store for the
test/router-dynamic-styles E2E fixture block.

Implements two style-scenario fixtures:

Bug A: activateDeferredStyle action mutates link.media from "not all"
to "all". The init callback reads --test-deferred-active via
getComputedStyle to verify the sheet survived navigation.

Bug B: init callback appends a <style> element without an id attribute,
simulating plugins like Complianz GDPR that bypass wp_enqueue_style().
sheet.disabled is checked directly after each navigation to confirm
the router never enrolled or disabled this element.

pluginStyleEl is module-scoped so it persists across SPA navigations
without relying on store-state serialisation.
Add render.php for the test/router-dynamic-styles E2E fixture block.

Enqueues deferred-style.css with media="not all" to simulate Bug A
(runtime-activated deferred stylesheets). Resolves sibling post URLs
by alias suffix (-b, -c) matching the titles set by addPostWithBlock,
and outputs all data-testid elements required by the spec:
deferred-style-active, plugin-style-active, activate-deferred-style,
nav-to-b, and nav-to-c.
Remove unused 'actions' and 'callbacks' destructuring from store() call. Collapse two ternary expressions onto single lines to satisfy prettier.
Collapse pluginStyleEl.textContent assignment onto a single line.
Merge the split boolean condition in pluginStyleStatus onto one line
to match prettier's expected formatting.
…view

Remove 'actions' and 'callbacks' from the store() destructuring assignment.
Both are registered implicitly by passing them to store() and do not need
to be captured in variables, so ESLint correctly flagged them as unused.
…tyle, use direct sheet checks

Root cause: init() used getComputedStyle to detect deferred stylesheet
activation via a CSS custom property. This approach fails silently when
deferred-style.css returns ERR_FAILED (404/network error in CI) — the
property is always "" and init() unconditionally overwrites
state.deferredStyleStatus back to "inactive" on every re-run, racing
against the action that just set it to "active".

Changes:
- Remove getComputedStyle entirely. deferredStyleStatus is now set only
  from activateDeferredStyle() (immediate click feedback) and from
  init() on re-navigation via link.sheet.disabled (direct DOM check).
- Introduce a plain module-level `deferredActivated` boolean instead of
  reactive state. Reading a non-reactive variable inside a callback does
  not create a reactive subscription, so init() cannot be re-triggered
  by the action's state mutation.
- Null-safe link.sheet handling: if the CSS file fails to load the
  browser sets sheet to null. Fall back to link.media !== "not all" as
  the activation signal (the router can only disable via sheet.disabled,
  not by reverting media).
- Bug B detection unchanged in logic; inline <style> elements always
  have a .sheet after appendChild in modern browsers.
…styles view

ESLint no-nested-ternary flagged the combined sheet/media
assignment. Split into an if/else block with a flat ternary on
each branch. No logic change.
…ule dependency

WordPress Script Modules reads {module}.asset.php to build the page
import map. Without this file @wordpress/interactivity has no map entry,
view.js silently fails to load, and all data-wp-init callbacks never run.
Adds data-wp-router-region to the block wrapper in render.php so the iAPI router intercepts link clicks and performs SPA navigation. Without this directive the router is inactive and every link click causes a full page reload, which resets module-level state and makes the deferred-stylesheet test scenario unreliable.
 Bug A and Bug B

Splits the single unreleased changelog entry into two separate lines — one for the deferred stylesheet fix (areNodesEqual media attribute comparison) and one for the dynamically-injected stylesheet fix (applyStyles enrollment gate). No functional changes.
…, not reference

applyStyles() used styles.includes(el) (reference equality). The router caches page.styles as Y-elements from the fetched document, while the live DOM holds original X-element references — different JavaScript objects. A runtime-activated deferred stylesheet (media="not all" → "all") would fail the reference check, causing routerManagedStyles to disable it on every navigation. Fix: pre-normalise the styles list once, then fall back to normalised equivalence matching when reference equality misses. Reference check is tried first (O(1)) so there is no performance cost for the common case.
…ManagedStyles seed

Two fixes for regressions in the E2E test run:

1. normalizeMedia: treat media="not all" as "all".
   WordPress uses media="not all" as a load-deferral trick; the intended
   scope is always "all". Without this, the live activated element
   (media="all") and the server-returned element (media="not all") differ
   after normalisation, the SCS algorithm treats them as separate resources,
   the live element is dropped from page.styles, and applyStyles() disables
   the activated sheet — the same Bug A that WordPress#76289 set out to fix.

2. routerManagedStyles: seed from all <link rel="stylesheet"> and <style>
   elements present in the DOM at module init, not just those with [id].
   Block-level <style> tags rendered server-side have no id attribute, so
   the previous [id]-only selector excluded them from the managed set and
   applyStyles() could never disable them, leaving stale styles from
   earlier pages active. Plugin-injected sheets (Complianz, etc.) are
   added via appendChild() after module init, so they are never in the
   initial DOM snapshot and remain unmanaged — Bug B protection intact.
… deferred sheets

applyStyles() matched DOM elements against page.styles using reference
equality (styles.includes(el)). When the router stores Y-elements (fetched
document) in page.styles, the live DOM element — a deferred sheet activated
at runtime (media="not all" → "all") — is a different object reference and
was incorrectly disabled on every navigation.

Add a normalised-equality fallback: pre-normalise the incoming styles list
once (O(N)) and use areNodesEqual() as a secondary check. normalizeMedia()
already treats media="not all" as "all", so the activated live element and
the server-returned element are recognised as the same resource.
- Updates the "deferred stylesheet" test to check the CSS custom property directly.
- This verifies the actual stylesheet state rather than relying on JS state hydration, which is subject to useInit lifecycle limitations.
@markusfoo
Copy link
Copy Markdown
Author

Hi @DAreRodz @luisherranz,

Gentle follow-up on #76289 (fixes #76031).

The current implementation:

  • Uses existing WordPress conventions (id="{handle}-css" + media="not all")
  • Requires zero changes from themes/plugins
  • Fixes both real-world bugs (deferred theme switchers + plugin-injected styles like Complianz/Woo mini-cart)
  • Keeps the router strictly managing only what it loaded itself

I addressed all previous feedback:

  • PR description trimmed to problem/fix/testing
  • areNodesEqual reverted to plain isEqualNode()
  • normalizeMedia is now the single source of truth for media handling

Live reproduction + Before/After videos still at markuss.cu.ma/blog.
All tests green, 145 commits of iteration done.

This change makes Interactivity API actually usable for real sites with dynamic CSS. Happy to jump on a call or iterate further if there's a better direction, but the current approach seems the most pragmatic and backward-compatible.

Would really appreciate a final review/approval so we can move this forward. Thanks!

@luisherranz
Copy link
Copy Markdown
Member

@markusfoo, thanks for your work here.

We will look at it in more detail once we start the iteration for WordPress 7.1, where we do want to include a fix in this regard.

@markusfoo
Copy link
Copy Markdown
Author

luisherranz

Thanks @luisherranz — good to know, looking forward to it. I'll keep the branch up to date with trunk in the meantime. Happy to iterate on anything if the team wants to take a different implementation direction for 7.1.

markusfoo added 19 commits April 7, 2026 23:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Interactivity Router /packages/interactivity-router

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for dynamically injected CSS

3 participants