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 Unlinked AccountsThe 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. 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.
…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.
…, 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.
|
Gentle follow-up on #76289 (fixes #76031). The current implementation:
I addressed all previous feedback:
Live reproduction + Before/After videos still at markuss.cu.ma/blog. 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! |
|
@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. |
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. |
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.tssilently setssheet.disabled = trueon 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 amediaparameter is a standard pattern for conditional stylesheets. A store activates such a sheet by mutatinglink.media. On the nextnavigate()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 frompage.styles, andapplyStyles()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 viadocument.head.appendChild(), bypassingwp_enqueue_style(). These elements are absent from every server-rendered response and never appear inpage.styles. The unconditionalelse { el.sheet.disabled = true }inapplyStyles()catches them on the firstnavigate()— 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:Both the live element (
media="all") and the server element (media="not all") now normalise to"all"beforeisEqualNode()sees them — they match.areNodesEqual()is back to plainisEqualNode().media="print"andmedia="screen"normalise to themselves — no collapse.Bug B — a module-level
routerManagedStylesSet seeded from elements with anidattribute:wp_enqueue_style()has generatedid="{handle}-css"since WordPress 2.6. Plugin-injected elements never carry one.applyStyles()now only disables enrolled elements:Elements enrolled on first activation out of
media="preload"state — the only path WP-managed sheets follow. Plugin elements never enter it.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). Nodocument.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
Files changed
packages/interactivity-router/src/assets/styles.tsnormalizeMedia,routerManagedStyles,applyStylespackages/interactivity-router/src/assets/test/styles.test.tstest/e2e/specs/interactivity/router-dynamic-styles.spec.tstest/e2e/specs/interactivity/plugins/interactive-blocks/router-dynamic-style/*