Fix: interactivity-router preserve dynamically-injected and deferred stylesheets across navigations#76289
Conversation
…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.
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
…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.
|
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.
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. |
…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.
Closes #76031
Related: Interactivity API: Roadmap #52904
The problem
Two independent bugs in
packages/interactivity-router/src/assets/styles.tscaused stylesheets to be silently disabled after every client-sidenavigate()call.No console errors. No 404s. The
<link>elements stayed in the DOM and were visible in DevTools. Onlysheet.disabled = true— invisible to anyone not specifically looking for it.Bug A — runtime-activated deferred stylesheets
WordPress enqueues optional stylesheets with
media="not all"as a load-deferral sentinel — the browser fetches the file but does not apply it. An iAPI store activates such a sheet by mutatinglink.mediato"all".Root cause:
areNodesEqual()usedisEqualNode(), which compares every attribute includingmedia. The live DOM element (media="all") and the server-returned element (media="not all") were treated as two different nodes by the SCS algorithm. The live element was dropped frompage.stylesandapplyStyles()disabled it on the nextnavigate()— silently resetting the user's theme.Fix:
areNodesEqual()now clones both nodes, strips themediaattribute from the clones, then callsisEqualNode(). All other attributes (integrity,crossorigin, etc.) are still compared. Only the one attribute that is legitimately mutable at runtime is excluded.Bug B — dynamically-injected plugin stylesheets
Plugins like Complianz GDPR append
<link>elements viadocument.head.appendChild(), bypassingwp_enqueue_style(). These elements:idattributepage.stylesRoot cause:
applyStyles()iteratedquerySelectorAll('style,link[rel=stylesheet]')and unconditionally disabled every element not inpage.styles. Plugin-injected stylesheets were always disabled on the firstnavigate().Fix:
routerManagedStylesis a module-levelSetseeded at init from elements that carry anidattribute.wp_enqueue_style()has always generatedid="{handle}-css"since WordPress 2.6 — this is a stable API contract. Plugin-injected elements never follow it. Only elements inrouterManagedStylesare candidates forsheet.disabled = true.Why
media="preload"activation is the enrollment gateAn element enters
routerManagedStylesthe first timeapplyStyles()activates it frommedia="preload"state. Plugin elements were never put through the preload cycle — they are never enrolled.Why init-time DOM snapshot was rejected
Seeding the set from all DOM elements at module evaluation time broke on back-navigation (A→B→C→A): page A was cached when
doc === window.document, sopage.stylescontained the plugin element. A subsequentapplyStyles(pageA.styles)would have enrolled it and the next navigation away from A would have disabled it. Themedia="preload"gate avoids this regardless of navigation path.Demo
Test site: https://markuss.cu.ma/blog/
The site runs Complianz GDPR and a theme-switcher block built with
@wordpress/interactivity(store(),state,actions,data-wp-on--click). Nodocument.head.appendChild()in the theme code — both scenarios reproduce naturally.Before this change: navigating from the blog index to any post silently disabled the Complianz consent banner CSS and reset the active theme variant. After this change both survive every navigation path including A→B→C→A.
Video — bug reproduction (before)
Video_260308000952.mp4
Video — after this change
Video_260308001244.mp4
Diagram
cc @luisherranz @DAreRodz