From b57409e091f1c798f6449276e977d5906ac11504 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Sun, 8 Mar 2026 00:56:19 +0200 Subject: [PATCH 01/79] interactivity-router: preserve dynamically-injected and runtime-activated 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 #76031. Ref #52904. --- .../interactivity-router/src/assets/styles.ts | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/interactivity-router/src/assets/styles.ts b/packages/interactivity-router/src/assets/styles.ts index 18d668dec5aefd..99f5fd54735c8d 100644 --- a/packages/interactivity-router/src/assets/styles.ts +++ b/packages/interactivity-router/src/assets/styles.ts @@ -6,15 +6,50 @@ import { shortestCommonSupersequence } from './scs'; export type StyleElement = HTMLLinkElement | HTMLStyleElement; /** - * Compares the passed style or link elements to check if they can be - * considered equal. + * Compares two style elements for equality, ignoring the `media` attribute. + * + * `media` is excluded because iAPI stores mutate it at runtime (e.g. a + * theme-switcher toggling `media="not all"` ↔ `"all"`). Using full + * `isEqualNode()` would treat those as different nodes and cause + * `applyStyles()` to disable the live element on the next navigation. + * Cloning and removing `media` before comparing preserves all other + * attributes (`integrity`, `crossorigin`, etc.) for a correct match. * * @param a `' . "\n"; +} + // Resolve sibling post URLs by alias (post title set by addPostWithBlock). $current_title = (string) get_the_title(); $base_alias = (string) preg_replace( '/-[a-z]$/', '', $current_title ); From cffcf5c998b0f0b5b58bd860c78115e46c4beb58 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Sun, 8 Mar 2026 21:42:13 +0200 Subject: [PATCH 53/79] test(e2e): inline plugin and deferred style fixtures in view.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deferred style: use server-rendered element by ID (no duplicate injection) - Plugin style: inject without id in init() (after router init) - Check sheet.disabled directly in init() for both cases - No external files — all logic inline --- .../router-dynamic-styles/view.js | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index 8bcc778541803f..5e32ac0093d0b5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -2,20 +2,21 @@ * View script for the test/router-dynamic-styles block. * * Bug A — deferred stylesheet (media="not all" → "all"): - * A ' . "\n"; + } +} + // Output the deferred-style fixture into exactly once per page. -// Using a named function + remove_action prevents duplicate output when -// more than one instance of this block exists on the same page. if ( ! has_action( 'wp_head', 'gutenberg_test_router_deferred_style' ) ) { add_action( 'wp_head', 'gutenberg_test_router_deferred_style', 20 ); } -/** - * Prints the inline deferred-style fixture for the router-dynamic-styles - * test block. Hooked to wp_head at priority 20. - * - * The element intentionally carries no src/href so the test never relies - * on resolving an external file. The id attribute is stable so view.js can - * retrieve it with getElementById(). - */ -function gutenberg_test_router_deferred_style() { - // Remove ourselves so subsequent pages in the same PHP process - // (e.g. REST-rendered block previews) do not duplicate the tag. - remove_action( 'wp_head', 'gutenberg_test_router_deferred_style', 20 ); - echo '' . "\n"; -} - // Resolve sibling post URLs by alias (post title set by addPostWithBlock). $current_title = (string) get_the_title(); $base_alias = (string) preg_replace( '/-[a-z]$/', '', $current_title ); From 473d62c33b2577c1f06815c662f2f7b6af213e3c Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Sun, 8 Mar 2026 22:54:22 +0200 Subject: [PATCH 55/79] test(e2e): replace module-level refs with DOM getters and stable id in router-dynamic-styles view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor view.js for the test/router-dynamic-styles block to use reactive DOM getters for state and a stable element id for the plugin-injected style fixture. State properties deferredStyleStatus and pluginStyleStatus are now derived getters that read the DOM on every access, removing the need for manual state.xxx assignments in actions and callbacks. This ensures both statuses reflect the actual DOM immediately after applyStyles() runs during SPA navigation, without relying on execution order between the router and iAPI directive re-init. The module-level pluginStyleEl reference and deferredActivated flag are removed. The plugin fixture element is now identified by a stable PLUGIN_STYLE_ID constant, making injection idempotent via a single getElementById check and allowing pluginStyleStatus to read the DOM directly without holding a stale element reference across navigations. activateDeferredStyle() no longer sets deferredActivated or writes to state — the getter reflects the media attribute change immediately. init() is reduced to the fixture injection guard only. --- .../router-dynamic-styles/view.js | 123 ++++++++---------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index 5e32ac0093d0b5..a43693ab46ec16 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -12,7 +12,7 @@ * keeps it in page.styles, and applyStyles() leaves it enabled. * * Bug B — plugin-injected stylesheet (no id, via appendChild): - * init() appends a ' . "\n"; - } -} - -// Output the deferred-style fixture into exactly once per page. -if ( ! has_action( 'wp_head', 'gutenberg_test_router_deferred_style' ) ) { - add_action( 'wp_head', 'gutenberg_test_router_deferred_style', 20 ); -} - // Resolve sibling post URLs by alias (post title set by addPostWithBlock). $current_title = (string) get_the_title(); $base_alias = (string) preg_replace( '/-[a-z]$/', '', $current_title ); @@ -74,6 +51,10 @@ function gutenberg_test_router_deferred_style() { $link_b = $find_url( $base_alias . '-b' ); $link_c = $find_url( $base_alias . '-c' ); ?> + + + +
Date: Mon, 9 Mar 2026 14:19:22 +0200 Subject: [PATCH 63/79] final(e2e): fix view.js therefore, init() is not restarted after navigation. After SPA navigation, Preact does not unmount/remount the block (it morphs the DOM). Therefore, init() is not restarted after navigation. --- .../router-dynamic-styles/view.js | 110 ++++++++++++------ 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index 58d81a3308d770..817460cc6a8eba 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -5,74 +5,116 @@ import { store } from '@wordpress/interactivity'; const PLUGIN_STYLE_ID = 'test-router-plugin-style'; +/** + * Access the router's reactive state so our computed getters re-evaluate + * automatically after every SPA navigation. + * + * `routerState.url` is updated inside the same `batch()` that calls + * `renderPage()`. Because `applyStyles()` runs before that batch flushes, + * any getter that reads `routerState.url` will observe the post-applyStyles + * DOM state when it is re-evaluated. + */ +const { state: routerState } = store( 'core/router', {} ); + const { state } = store( 'test/router-dynamic-styles', { state: { /** - * "active" | "inactive". Written by activateDeferredStyle() and - * re-synchronised from the real DOM by init() on every SPA mount. + * Internal toggle flipped by activateDeferredStyle(). Forces the + * deferredStyleStatus getter to re-evaluate on click, since clicking + * does not change routerState.url. + * + * @type {boolean} + */ + _deferredActivated: false, + + /** + * Internal flag set by init() after the plugin style is injected. + * Forces pluginStyleStatus to re-evaluate after the element appears, + * since injection does not change routerState.url. + * + * @type {boolean} + */ + _pluginStyleInjected: false, + + /** + * "active" | "inactive". Computed getter that reads the real DOM on + * every SPA navigation (via routerState.url) and on every click of + * "activate-deferred-style" (via state._deferredActivated). + * + * Reading routerState.url establishes a Preact signal dependency: the + * router sets url inside the same batch as applyStyles(), so when the + * signal fires the DOM already reflects the post-applyStyles state. * - * Must be a plain reactive field, not a DOM getter: raw DOM reads are - * not tracked by Preact's signal system, so a getter would be evaluated - * once at hydration and never again, leaving the span stuck at "inactive". + * @return {"active"|"inactive"} Whether the deferred stylesheet is active. */ - deferredStyleStatus: 'inactive', + get deferredStyleStatus() { + // Establish reactive dependencies so getter re-runs on nav + click. + void routerState.url; + void state._deferredActivated; + + const el = document.getElementById( + 'test-router-deferred-style' + ); + return el?.sheet && ! el.sheet.disabled && el.media !== 'not all' + ? 'active' + : 'inactive'; + }, /** - * "active" | "inactive". Written by init() on every SPA mount. - * Plain reactive field for the same reason as deferredStyleStatus. + * "active" | "inactive". Computed getter that reads the real DOM on + * every SPA navigation (via routerState.url) and after init() injects + * the element (via state._pluginStyleInjected). + * + * @return {"active"|"inactive"} Whether the plugin stylesheet is present. */ - pluginStyleStatus: 'inactive', + get pluginStyleStatus() { + // Establish reactive dependencies. + void routerState.url; + void state._pluginStyleInjected; + + const el = document.getElementById( PLUGIN_STYLE_ID ); + return el?.sheet && ! el.sheet.disabled ? 'active' : 'inactive'; + }, }, actions: { /** * Bug A fixture — activates the deferred stylesheet. * - * Mutates media from "not all" to "all" on the inline style element, then - * writes the reactive signal so data-wp-text re-renders immediately. + * Sets element.media from "not all" to "all", then toggles + * state._deferredActivated so the deferredStyleStatus getter + * re-evaluates and the span re-renders immediately. */ activateDeferredStyle() { - const el = document.getElementById( 'test-router-deferred-style' ); + const el = document.getElementById( + 'test-router-deferred-style' + ); if ( el ) { el.media = 'all'; - state.deferredStyleStatus = 'active'; + state._deferredActivated = ! state._deferredActivated; } }, }, callbacks: { /** - * Runs on every SPA page mount (data-wp-init). - * - * Execution order per navigation: - * 1. applyStyles() — router enables / disables sheets. - * 2. iAPI re-initialises directives, calling this callback. + * Runs once on initial mount (data-wp-init = useEffect([], [])). * - * Reading the DOM here therefore always reflects the post-applyStyles - * state, allowing both signals to be synchronised accurately. + * Bug B: injects the plugin-style element idempotently, then sets + * state._pluginStyleInjected = true so pluginStyleStatus + * re-evaluates and the span re-renders immediately. * - * Bug B: injects the plugin-style element on first mount; subsequent - * mounts confirm it survived applyStyles() without being disabled. - * - * Bug A: re-reads sheet.disabled so deferredStyleStatus reflects whether - * applyStyles() correctly preserved the activated sheet. + * deferredStyleStatus is handled entirely by the computed getter — + * no DOM sync needed here. */ init() { - // Bug B — idempotent plugin-style injection. if ( ! document.getElementById( PLUGIN_STYLE_ID ) ) { const style = document.createElement( 'style' ); style.id = PLUGIN_STYLE_ID; style.textContent = 'body { --test-plugin-style: 1; }'; document.head.appendChild( style ); } - state.pluginStyleStatus = 'active'; - - // Bug A — re-sync deferred status after applyStyles(). - const el = document.getElementById( 'test-router-deferred-style' ); - state.deferredStyleStatus = - el?.sheet && ! el.sheet.disabled && el.media !== 'not all' - ? 'active' - : 'inactive'; + state._pluginStyleInjected = true; }, }, } ); From 6d4200c011328cdea554a9a3c226327c7d777ab1 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 16:05:46 +0200 Subject: [PATCH 64/79] test(e2e): wire navigation links to iAPI router in router-dynamic-styles fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds data-wp-on--click="actions.navigate" to both nav-to-b and nav-to-c anchor elements in the router-dynamic-styles block template. Previously the links had no iAPI directive and triggered standard browser navigation (full page reload) on click. The Playwright test clicked nav-to-b expecting a SPA navigation — one where the router fetches the next page, runs updateStylesWithSCS, and calls applyStyles() to verify that the runtime-activated deferred stylesheet survives. Instead the browser reloaded the page from scratch, resetting all JS state, and the test saw deferredStyleStatus = 'inactive' unconditionally regardless of the styles.ts fix. With this directive in place the router intercepts the click, performs client-side navigation, and applyStyles() runs with the full styles list from the fetched page — which is exactly the code path the areNodesEqual fix in styles.ts is designed to protect. --- .../interactive-blocks/router-dynamic-styles/render.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php index 805ef5cef8d5d7..3c19e06ea408f4 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php @@ -6,7 +6,7 @@ * * Bug A — runtime-activated deferred stylesheets: * A
Date: Mon, 9 Mar 2026 17:03:55 +0200 Subject: [PATCH 68/79] fix(e2e): Error - Invalid JSDoc tag name Error - Invalid JSDoc tag name "wordpress/interactivity-router". (jsdoc/check-tag-names) --- .../plugins/interactive-blocks/router-dynamic-styles/view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index 30dea370eddf15..4f818e4c33f102 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -50,14 +50,14 @@ const { state } = store( 'test/router-dynamic-styles', { * activated stylesheet across navigation. * * Uses a generator function so the dynamic import of - * @wordpress/interactivity-router can be yielded (iAPI async pattern). + * `@wordpress/interactivity-router` can be yielded (iAPI async pattern). * * @param {MouseEvent} event The click event from data-wp-on--click. */ * navigate( event ) { event.preventDefault(); const { ref } = getElement(); - const { actions } = yield import( '@wordpress/interactivity-router' ); + const { actions } = yield import('@wordpress/interactivity-router'); yield actions.navigate( ref.href ); }, }, From 7f6a9dde4df822cfbf186f234b691b38221a1302 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 17:17:39 +0200 Subject: [PATCH 69/79] fix(e2e): prettier error view.js --- .../interactive-blocks/router-dynamic-styles/view.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index 4f818e4c33f102..dffc2da029b621 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -54,11 +54,13 @@ const { state } = store( 'test/router-dynamic-styles', { * * @param {MouseEvent} event The click event from data-wp-on--click. */ - * navigate( event ) { + *navigate(event) { event.preventDefault(); const { ref } = getElement(); - const { actions } = yield import('@wordpress/interactivity-router'); - yield actions.navigate( ref.href ); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate(ref.href); }, }, From 489e82ad2de3af4b58b5630b0685256dff189b22 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 17:32:06 +0200 Subject: [PATCH 70/79] fix(e2e): prettier spacing in router-dynamic-styles view.js --- .../plugins/interactive-blocks/router-dynamic-styles/view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index dffc2da029b621..dc491d58db18c5 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -54,13 +54,13 @@ const { state } = store( 'test/router-dynamic-styles', { * * @param {MouseEvent} event The click event from data-wp-on--click. */ - *navigate(event) { + *navigate( event ) { event.preventDefault(); const { ref } = getElement(); const { actions } = yield import( '@wordpress/interactivity-router' ); - yield actions.navigate(ref.href); + yield actions.navigate( ref.href ); }, }, From 870a1f0dfa3ae38738ba124086eddcd109731c02 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 20:30:37 +0200 Subject: [PATCH 71/79] fix(e2e): call actions.navigate without yield in router-dynamic-styles fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigate generator action was calling `yield actions.navigate(ref.href)`. This returned the wrapped action object back into the iAPI generator runner, which attempted to process it as a Promise or nested Generator — causing unpredictable behaviour during back-navigation and breaking the history state for the A→B→C→A test path. The correct pattern (matching full-page.ts) is to call actions.navigate() without yield — the navigation runs inside the iAPI runtime as fire-and-forget. The generator is only needed to yield the dynamic import itself. Changed: `yield actions.navigate( ref.href )` → `actions.navigate( ref.href )` --- .../interactive-blocks/router-dynamic-styles/view.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js index dc491d58db18c5..9722a4a6b1ca02 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/view.js @@ -49,8 +49,9 @@ const { state } = store( 'test/router-dynamic-styles', { * making it impossible to verify that applyStyles() preserved the * activated stylesheet across navigation. * - * Uses a generator function so the dynamic import of - * `@wordpress/interactivity-router` can be yielded (iAPI async pattern). + * Uses a generator function to yield the dynamic import. + * actions.navigate() is called without yield (fire-and-forget), + * matching the pattern used in full-page.ts. * * @param {MouseEvent} event The click event from data-wp-on--click. */ @@ -60,7 +61,7 @@ const { state } = store( 'test/router-dynamic-styles', { const { actions } = yield import( '@wordpress/interactivity-router' ); - yield actions.navigate( ref.href ); + actions.navigate( ref.href ); }, }, From 0157c20fceeccd1b795a2e5c095f3e20d909b6ab Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 20:32:33 +0200 Subject: [PATCH 72/79] fix(e2e): await waitForURL after goBack in back-navigation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The back-navigation test (A→B→C→A) called page.goBack() twice in sequence and then immediately asserted on the DOM. page.goBack() resolves as soon as the browser changes the URL, but the iAPI router processes the popstate event asynchronously — it re-renders the router region after goBack() has already resolved. This left the DOM in a transitional state and caused getByTestId('plugin-style-active') to not be found within the 5000ms timeout. Added page.waitForURL() after each goBack() call to ensure the iAPI router has committed the navigation and updated the DOM before the assertion runs. Changed: await page.goBack(); await page.goBack(); → await page.goBack(); await page.waitForURL( utils.getLink( 'router-dynamic-styles-b' ) ); await page.goBack(); await page.waitForURL( utils.getLink( 'router-dynamic-styles-a' ) ); --- test/e2e/specs/interactivity/router-dynamic-styles.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts index 9cc8efabfea5a8..5fab9e0d1228de 100644 --- a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts +++ b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts @@ -155,8 +155,12 @@ test.describe( 'Interactivity API router dynamic styles', () => { ); // Back to A (cached page). + // waitForURL ensures the iAPI router's popstate handler has + // committed the navigation before we assert on the DOM. await page.goBack(); + await page.waitForURL( utils.getLink( 'router-dynamic-styles-b' ) ); await page.goBack(); + await page.waitForURL( utils.getLink( 'router-dynamic-styles-a' ) ); await expect( page.getByTestId( 'plugin-style-active' ) ).toHaveText( 'active' ); From 5b370e6bbf530dc5bb2034da6891fb17a94119fb Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 21:05:53 +0200 Subject: [PATCH 73/79] fix(e2e): replace waitForURL with waitForFunction for SPA back-navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForURL() waits for a full page load event by default. page.goBack() in a SPA context fires popstate only — the iAPI router handles it asynchronously without triggering a load event. This caused waitForURL to hang until the 100s test timeout, after which Playwright forcibly closed the browser (Target page, context or browser has been closed). Replaced with waitForFunction() which polls window.location.href directly in the browser context. This resolves as soon as the URL changes after popstate, regardless of whether a load event fires. --- .../interactivity/router-dynamic-styles.spec.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts index 5fab9e0d1228de..01289d449c490d 100644 --- a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts +++ b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts @@ -155,12 +155,17 @@ test.describe( 'Interactivity API router dynamic styles', () => { ); // Back to A (cached page). - // waitForURL ensures the iAPI router's popstate handler has - // committed the navigation before we assert on the DOM. + // waitForFunction polls window.location in the browser context — + // this works for SPA popstate navigation where no full page load + // event fires (waitForURL would hang forever in that case). await page.goBack(); - await page.waitForURL( utils.getLink( 'router-dynamic-styles-b' ) ); + await page.waitForFunction( ( url: string ) => + window.location.href.startsWith( url ), + utils.getLink( 'router-dynamic-styles-b' ) ); await page.goBack(); - await page.waitForURL( utils.getLink( 'router-dynamic-styles-a' ) ); + await page.waitForFunction( ( url: string ) => + window.location.href.startsWith( url ), + utils.getLink( 'router-dynamic-styles-a' ) ); await expect( page.getByTestId( 'plugin-style-active' ) ).toHaveText( 'active' ); From aaf61f6492368deb3be9bd97d23cbee305c8fd56 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 21:13:58 +0200 Subject: [PATCH 74/79] fix(e2e): use history.back + toHaveURL for SPA back-navigation in spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page.goBack() waits for a load event by default, which never fires during SPA popstate navigation. This caused the test to hang for 100s before Playwright killed the browser. waitForFunction with a callback argument also had incorrect prettier formatting (argument indentation mismatch). Replaced both with: await page.evaluate( () => window.history.back() ); await expect( page ).toHaveURL( ... ); page.evaluate resolves immediately after triggering popstate. expect(page).toHaveURL polls page.url() until it matches — no load event required, no formatting issues. --- .../router-dynamic-styles.spec.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts index 01289d449c490d..3cd02c58f8992f 100644 --- a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts +++ b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts @@ -155,17 +155,13 @@ test.describe( 'Interactivity API router dynamic styles', () => { ); // Back to A (cached page). - // waitForFunction polls window.location in the browser context — - // this works for SPA popstate navigation where no full page load - // event fires (waitForURL would hang forever in that case). - await page.goBack(); - await page.waitForFunction( ( url: string ) => - window.location.href.startsWith( url ), - utils.getLink( 'router-dynamic-styles-b' ) ); - await page.goBack(); - await page.waitForFunction( ( url: string ) => - window.location.href.startsWith( url ), - utils.getLink( 'router-dynamic-styles-a' ) ); + // page.evaluate( history.back ) fires popstate without waiting for a + // load event (which never fires in SPA context). expect(page).toHaveURL + // then polls page.url() until the URL matches — no load event needed. + await page.evaluate( () => window.history.back() ); + await expect( page ).toHaveURL( utils.getLink( 'router-dynamic-styles-b' ) ); + await page.evaluate( () => window.history.back() ); + await expect( page ).toHaveURL( utils.getLink( 'router-dynamic-styles-a' ) ); await expect( page.getByTestId( 'plugin-style-active' ) ).toHaveText( 'active' ); From b0926af9e4528184a89ba20d2aa7abb3f30f11f4 Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 21:22:38 +0200 Subject: [PATCH 75/79] fix(e2e): fix prettier formatting for toHaveURL calls in back-navigation test The toHaveURL assertions with utils.getLink() argument exceeded prettier's line length limit. Prettier requires the argument to be placed on a new line with closing parenthesis on its own line. Split each toHaveURL call across three lines to comply with the WordPress prettier config. --- .../e2e/specs/interactivity/router-dynamic-styles.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts index 3cd02c58f8992f..e8bec8a95227bf 100644 --- a/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts +++ b/test/e2e/specs/interactivity/router-dynamic-styles.spec.ts @@ -159,9 +159,13 @@ test.describe( 'Interactivity API router dynamic styles', () => { // load event (which never fires in SPA context). expect(page).toHaveURL // then polls page.url() until the URL matches — no load event needed. await page.evaluate( () => window.history.back() ); - await expect( page ).toHaveURL( utils.getLink( 'router-dynamic-styles-b' ) ); + await expect( page ).toHaveURL( + utils.getLink( 'router-dynamic-styles-b' ) + ); await page.evaluate( () => window.history.back() ); - await expect( page ).toHaveURL( utils.getLink( 'router-dynamic-styles-a' ) ); + await expect( page ).toHaveURL( + utils.getLink( 'router-dynamic-styles-a' ) + ); await expect( page.getByTestId( 'plugin-style-active' ) ).toHaveText( 'active' ); From 96104797d922f0c0b08953926fac3970cc81d95d Mon Sep 17 00:00:00 2001 From: "Marcus Karlos (S.K)" Date: Mon, 9 Mar 2026 21:52:54 +0200 Subject: [PATCH 76/79] test(e2e): add nav-to-a link to router-dynamic-styles fixture --- .../interactive-blocks/router-dynamic-styles/render.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php index 9c811243c3b2a9..78a0a8fe8d25e6 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/router-dynamic-styles/render.php @@ -29,7 +29,7 @@ * would cause full page reloads, resetting all state and making the test * unable to verify that styles survive navigation. * - * Navigation links nav-to-b and nav-to-c resolve sibling posts by title so + * Navigation links nav-to-a, nav-to-b and nav-to-c resolve sibling posts by title so * the spec's addPostWithBlock( …, { alias } ) pattern works out of the box. * * @package gutenberg-test-interactive-blocks @@ -53,6 +53,7 @@ return $posts ? (string) get_permalink( $posts[0] ) : '#'; }; +$link_a = $find_url( $base_alias . '-a' ); $link_b = $find_url( $base_alias . '-b' ); $link_c = $find_url( $base_alias . '-c' ); ?> @@ -87,6 +88,11 @@ Activate deferred style