From 8d83177b0f1c786915e12c049e84ff8642b81681 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 May 2026 15:34:55 -0700 Subject: [PATCH 01/17] Refine the admin menu view transition animation. Clip each top-level menu item's view transition snapshot to its animating group box. By default a captured snapshot keeps its full intrinsic height, so a newly expanded submenu painted at full height from the first frame and overlapped the menu items below while they were still sliding into place. Setting `overflow: clip` on the image pair makes the submenu wipe open, and closed in reverse, in step with those items. Also name the collapse button so it slides into place with the menu items rather than cross-fading via the page root, and add comments explaining each rule. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-admin/css/view-transitions.css | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/wp-admin/css/view-transitions.css b/src/wp-admin/css/view-transitions.css index 538a90b502f9e..619a74be8c660 100644 --- a/src/wp-admin/css/view-transitions.css +++ b/src/wp-admin/css/view-transitions.css @@ -3,7 +3,40 @@ navigation: auto; } + /* + * Give each top-level menu item its own view transition name — its id, + * such as "menu-posts" — so each one is captured as an independent group + * and animates from its old position and size to its new ones across a + * navigation. This produces the accordion effect: the previously open + * section collapses as the newly current one expands, instead of the + * whole menu simply cross-fading. attr() falls back to "none" for any + * item whose id is missing or is not a valid custom identifier. The + * shared wp-menu-top class lets the image-pair rule below address every + * menu-top group at once. + */ #adminmenu > .menu-top { view-transition-name: attr(id type(), none); + view-transition-class: wp-menu-top; + } + + /* + * Name the collapse button too, so it slides into place with the menu + * items instead of cross-fading as part of the page root. + */ + #collapse-menu { + view-transition-name: collapse-menu; + } + + /* + * Clip each menu-top's snapshot to its animating group box. By default a + * captured snapshot keeps its full intrinsic height, so the new (expanded) + * submenu paints at full height from the first frame and overlaps the + * items below while they are still sliding down. Clipping the image pair + * to the group — whose height animates from the link row to the full + * expanded height — makes the submenu wipe open, and closed in reverse, + * in step with those items. + */ + ::view-transition-image-pair(.wp-menu-top) { + overflow: clip; } } From 867f8e39dec328710e790e73ee2e82802719540e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 May 2026 17:17:56 -0700 Subject: [PATCH 02/17] Render-block admin pages so View Transitions capture a complete page. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cross-document view transition snapshots the incoming page at its first render, which can occur before the body has finished parsing. When that happens the admin menu is only partially built in the snapshot, so the menu-top elements have no height delta and the section accordion silently degrades to a plain cross-fade. Print a render-blocking `` pointed at the end of the body, so the first render — and therefore the transition's snapshot — waits until the page is fully parsed. The media query scopes the small first-render delay to when view transitions run. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-includes/default-filters.php | 1 + src/wp-includes/view-transitions.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index a4ecf677e2af1..0f1110bfee892 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -603,6 +603,7 @@ add_action( 'admin_enqueue_scripts', 'wp_common_block_scripts_and_styles' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ); +add_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ); add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' ); add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); add_action( 'enqueue_block_assets', 'enqueue_block_styles_assets', 30 ); diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 2d2ad61e0b431..e6d9bdf075b71 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -28,3 +28,24 @@ function wp_get_view_transitions_admin_css(): string { $path = ABSPATH . "wp-admin/css/view-transitions{$affix}.css"; return file_get_contents( $path ); } + +/** + * Prints a render-blocking expectation so View Transitions capture a fully parsed admin page. + * + * A cross-document view transition snapshots the incoming page at its first render, which can + * happen before the `` has finished parsing. The snapshot would then capture a partially + * built page — for example an incomplete admin menu — and the transition animates to or from + * the wrong state. + * + * The `expect` link blocks the first render until an element near the end of the body is + * parsed, so the snapshot is always taken of a complete page. The media query limits the + * (small) first-render delay to when view transitions actually run, matching the + * `@view-transition` rule in view-transitions.css. + * + * @since 7.0.1 + * + * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect + */ +function wp_print_view_transitions_render_blocking_link(): void { + echo '' . "\n"; +} From b5abf8de2b11c8fceb725e813d51cb43516cd2bf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 20 May 2026 17:24:38 -0700 Subject: [PATCH 03/17] Address PHPStan error: Function wp_get_view_transitions_admin_css() should return string but returns string|false. --- src/wp-includes/view-transitions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index e6d9bdf075b71..784482dd48d36 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -26,7 +26,7 @@ function wp_enqueue_view_transitions_admin_css(): void { function wp_get_view_transitions_admin_css(): string { $affix = SCRIPT_DEBUG ? '' : '.min'; $path = ABSPATH . "wp-admin/css/view-transitions{$affix}.css"; - return file_get_contents( $path ); + return (string) file_get_contents( $path ); } /** From 59f64027130a384909eead83847838216d2a73ef Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 2 Jun 2026 13:57:00 +0200 Subject: [PATCH 04/17] Tests: Add unit tests for `wp_print_view_transitions_render_blocking_link()`. Covers that the function is hooked at `admin_head` and that it prints a single render-blocking `` tag with the expected attributes. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...PrintViewTransitionsRenderBlockingLink.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php new file mode 100644 index 0000000000000..d9ef2f5f56d5a --- /dev/null +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -0,0 +1,40 @@ +assertSame( 10, has_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ) ); + } + + /** + * Tests that a render-blocking expect link is printed. + * + * @ticket 65032 + */ + public function test_prints_render_blocking_link(): void { + $output = get_echo( 'wp_print_view_transitions_render_blocking_link' ); + + $processor = new WP_HTML_Tag_Processor( $output ); + $this->assertTrue( $processor->next_tag( 'LINK' ), 'Expected a LINK tag to be printed.' ); + $this->assertSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected the LINK to have a rel of "expect".' ); + $this->assertSame( '#wpfooter', $processor->get_attribute( 'href' ), 'Expected the LINK to point at the footer.' ); + $this->assertSame( 'render', $processor->get_attribute( 'blocking' ), 'Expected the LINK to block rendering.' ); + $this->assertSame( '(prefers-reduced-motion: no-preference)', $processor->get_attribute( 'media' ), 'Expected the LINK to be scoped to the view transitions media query.' ); + $this->assertFalse( $processor->next_tag(), 'Expected only a single tag to be printed.' ); + } +} From 5739a3d656f48469b00e9e349315c6acb4447f73 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 8 Sep 2026 14:42:34 -0700 Subject: [PATCH 05/17] Bump since tag to 7.2.0 --- src/wp-includes/view-transitions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 784482dd48d36..d16fad3c866e2 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -42,7 +42,7 @@ function wp_get_view_transitions_admin_css(): string { * (small) first-render delay to when view transitions actually run, matching the * `@view-transition` rule in view-transitions.css. * - * @since 7.0.1 + * @since 7.2.0 * * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect */ From 326d29e5c5b431c859c4959871754a044964cff4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:01:39 -0700 Subject: [PATCH 06/17] Point the render-blocking expect link at `#wpcontent`. Waiting for `#wpfooter` held the first render until all of the page content had been parsed, which on an `edit.php` list table with 109 posts delayed FCP by about 84 ms at 4x CPU throttling, and more when the server streams a slow page. Chrome unblocks rendering once the target's start tag is parsed, not its children, so `#adminmenu` would not guarantee that the menu items are parsed. `#wpcontent` opens right after the menu closes, so its start tag means the whole menu has been parsed, and it adds no measurable delay compared with having no expect link at all. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 13 ++++++++----- .../wpPrintViewTransitionsRenderBlockingLink.php | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index d16fad3c866e2..6799e9e74bc01 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -30,16 +30,19 @@ function wp_get_view_transitions_admin_css(): string { } /** - * Prints a render-blocking expectation so View Transitions capture a fully parsed admin page. + * Prints a render-blocking expectation so View Transitions capture a fully parsed admin menu. * * A cross-document view transition snapshots the incoming page at its first render, which can * happen before the `` has finished parsing. The snapshot would then capture a partially * built page — for example an incomplete admin menu — and the transition animates to or from * the wrong state. * - * The `expect` link blocks the first render until an element near the end of the body is - * parsed, so the snapshot is always taken of a complete page. The media query limits the - * (small) first-render delay to when view transitions actually run, matching the + * The `expect` link blocks the first render until `#wpcontent` is parsed. Rendering is unblocked + * as soon as the target's start tag is parsed, not its children, so the target is the element + * right after the admin menu rather than the menu itself: once `#wpcontent` starts, the whole + * menu has been parsed. Targeting an element near the end of the body would also work, but it + * would delay the first render by the time it takes to parse all of the page content. The media + * query limits the first-render delay to when view transitions actually run, matching the * `@view-transition` rule in view-transitions.css. * * @since 7.2.0 @@ -47,5 +50,5 @@ function wp_get_view_transitions_admin_css(): string { * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect */ function wp_print_view_transitions_render_blocking_link(): void { - echo '' . "\n"; + echo '' . "\n"; } diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index d9ef2f5f56d5a..6f5b51d9f3617 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -32,7 +32,7 @@ public function test_prints_render_blocking_link(): void { $processor = new WP_HTML_Tag_Processor( $output ); $this->assertTrue( $processor->next_tag( 'LINK' ), 'Expected a LINK tag to be printed.' ); $this->assertSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected the LINK to have a rel of "expect".' ); - $this->assertSame( '#wpfooter', $processor->get_attribute( 'href' ), 'Expected the LINK to point at the footer.' ); + $this->assertSame( '#wpcontent', $processor->get_attribute( 'href' ), 'Expected the LINK to point at the element after the admin menu.' ); $this->assertSame( 'render', $processor->get_attribute( 'blocking' ), 'Expected the LINK to block rendering.' ); $this->assertSame( '(prefers-reduced-motion: no-preference)', $processor->get_attribute( 'media' ), 'Expected the LINK to be scoped to the view transitions media query.' ); $this->assertFalse( $processor->next_tag(), 'Expected only a single tag to be printed.' ); From 04571207520020c45af98cd19adf851096bda75b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:02:22 -0700 Subject: [PATCH 07/17] Only print the expect link when the View Transitions style is enqueued. The link was printed on every admin screen regardless of whether the `wp-view-transitions-admin` style was enqueued. When a plugin dequeues the style or unhooks `wp_enqueue_view_transitions_admin_css()`, the first render was still delayed with no transition to show for it. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 7 ++++ ...PrintViewTransitionsRenderBlockingLink.php | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 6799e9e74bc01..f5772d808e116 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -45,10 +45,17 @@ function wp_get_view_transitions_admin_css(): string { * query limits the first-render delay to when view transitions actually run, matching the * `@view-transition` rule in view-transitions.css. * + * Nothing is printed when the admin View Transitions style is not enqueued, since there is then + * no transition to wait for. + * * @since 7.2.0 * * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect */ function wp_print_view_transitions_render_blocking_link(): void { + if ( ! wp_style_is( 'wp-view-transitions-admin' ) ) { + return; + } + echo '' . "\n"; } diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index 6f5b51d9f3617..68e6b96fc56fc 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -12,6 +12,28 @@ */ class Tests_View_Transitions_wpPrintViewTransitionsRenderBlockingLink extends WP_UnitTestCase { + private ?WP_Styles $original_wp_styles = null; + + /** + * Saves the global styles instance and resets it so each test starts with nothing enqueued. + * + * @global WP_Styles|null $wp_styles + */ + public function set_up() { + global $wp_styles; + + parent::set_up(); + $this->original_wp_styles = $wp_styles; + $wp_styles = null; + } + + public function tear_down() { + global $wp_styles; + + $wp_styles = $this->original_wp_styles; + parent::tear_down(); + } + /** * Tests that the hook for printing the render-blocking link is set up. * @@ -27,6 +49,7 @@ public function test_hook(): void { * @ticket 65032 */ public function test_prints_render_blocking_link(): void { + wp_enqueue_view_transitions_admin_css(); $output = get_echo( 'wp_print_view_transitions_render_blocking_link' ); $processor = new WP_HTML_Tag_Processor( $output ); @@ -37,4 +60,14 @@ public function test_prints_render_blocking_link(): void { $this->assertSame( '(prefers-reduced-motion: no-preference)', $processor->get_attribute( 'media' ), 'Expected the LINK to be scoped to the view transitions media query.' ); $this->assertFalse( $processor->next_tag(), 'Expected only a single tag to be printed.' ); } + + /** + * Tests that nothing is printed when the admin View Transitions style is not enqueued. + * + * @ticket 65032 + */ + public function test_prints_nothing_when_style_not_enqueued(): void { + $this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' ); + $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); + } } From 7f3b82e6edf1bf933b02df465775205ed4eab3bb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:02:56 -0700 Subject: [PATCH 08/17] Skip the expect link for iframe requests. `admin_head` also fires from `iframe_header()` and `wp_iframe()`, whose screens have no admin menu. An expect link whose target never appears blocks the first render until the whole document has been parsed, footer scripts included, which delayed FCP by about 280 ms on `media-upload.php` and 260 ms on the plugin-information modal at 4x CPU throttling. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 6 ++++-- .../wpPrintViewTransitionsRenderBlockingLink.php | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index f5772d808e116..531b5176921be 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -46,14 +46,16 @@ function wp_get_view_transitions_admin_css(): string { * `@view-transition` rule in view-transitions.css. * * Nothing is printed when the admin View Transitions style is not enqueued, since there is then - * no transition to wait for. + * no transition to wait for. Nothing is printed for iframe requests either, such as Thickbox + * modals: they have no admin menu, and an `expect` link whose target never appears blocks the + * first render until the whole document has been parsed. * * @since 7.2.0 * * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect */ function wp_print_view_transitions_render_blocking_link(): void { - if ( ! wp_style_is( 'wp-view-transitions-admin' ) ) { + if ( ! wp_style_is( 'wp-view-transitions-admin' ) || defined( 'IFRAME_REQUEST' ) ) { return; } diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index 68e6b96fc56fc..0af2d5ff5bb12 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -70,4 +70,19 @@ public function test_prints_nothing_when_style_not_enqueued(): void { $this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' ); $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); } + + /** + * Tests that nothing is printed for an iframe request, which has no admin menu. + * + * @ticket 65032 + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_prints_nothing_for_iframe_request(): void { + define( 'IFRAME_REQUEST', true ); + wp_enqueue_view_transitions_admin_css(); + + $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); + } } From 2ba00cf0a9a4cad9649d4c1f098083f7e10eadc5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:51:46 -0700 Subject: [PATCH 09/17] Enqueue admin View Transitions only from admin-header.php. Hooking `wp_enqueue_view_transitions_admin_css()` to `admin_enqueue_scripts` also enqueued the style on iframe screens, since `iframe_header()` and `wp_iframe()` fire that action too, even though those screens have no admin menu to animate. Enqueuing it from admin-header.php, next to `colors`, makes it opt-in for the screens that print the menu, including iframe screens that do not define `IFRAME_REQUEST`, such as the theme-information modal. To disable admin View Transitions, a plugin should now dequeue the `wp-view-transitions-admin` style rather than unhook the function. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/admin-header.php | 6 ++++++ src/wp-includes/default-filters.php | 1 - src/wp-includes/view-transitions.php | 4 ++++ .../wpEnqueueViewTransitionsAdminCss.php | 9 ++++++--- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php index 055d801533701..36962c3e44326 100644 --- a/src/wp-admin/admin-header.php +++ b/src/wp-admin/admin-header.php @@ -103,6 +103,12 @@ wp_enqueue_script( 'utils' ); wp_enqueue_script( 'svg-painter' ); +/* + * View transitions animate the admin menu, so they are only enqueued on screens that print it. + * Iframe screens such as Thickbox modals do not load this header and so do not opt in. + */ +wp_enqueue_view_transitions_admin_css(); + $admin_body_class = preg_replace( '/[^a-z0-9_-]+/i', '-', $hook_suffix ); // Print the global admin inline scripts through the script tag API so the diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index fbec4c1916292..7f5f9d0fb0eb8 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -634,7 +634,6 @@ add_action( 'admin_enqueue_scripts', 'wp_localize_jquery_ui_datepicker', 1000 ); add_action( 'admin_enqueue_scripts', 'wp_common_block_scripts_and_styles' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ); -add_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ); add_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ); add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' ); add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 531b5176921be..b00b5a823b2e1 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -10,7 +10,11 @@ /** * Enqueues View Transitions CSS for the admin. * + * To disable admin View Transitions, dequeue the `wp-view-transitions-admin` style at the + * `admin_enqueue_scripts` action, rather than unhooking this function. + * * @since 7.0.0 + * @since 7.2.0 Called from wp-admin/admin-header.php instead of hooked to `admin_enqueue_scripts`. */ function wp_enqueue_view_transitions_admin_css(): void { wp_enqueue_style( 'wp-view-transitions-admin' ); diff --git a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php index 874bd11bb70fb..1f112a255ff8a 100644 --- a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php +++ b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php @@ -29,12 +29,15 @@ public function tear_down() { } /** - * Tests that the hook for enqueuing admin view transitions CSS is set up. + * Tests that admin view transitions CSS is not enqueued via a hook. + * + * It is enqueued from wp-admin/admin-header.php instead, so iframe screens without the admin menu do not get it. * * @ticket 64470 + * @ticket 65032 */ - public function test_hook() { - $this->assertSame( 10, has_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) ); + public function test_not_hooked() { + $this->assertFalse( has_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) ); } /** From a8d146e97ee86e595baa2f6c44beb9769b40a792 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:52:42 -0700 Subject: [PATCH 10/17] Only print the expect link once the View Transitions style is printed. Checking that the `wp-view-transitions-admin` style is `done` rather than merely enqueued ties the link to the CSS actually having been output in the head. If the style is enqueued too late, it is printed in the footer, and there is no transition at first render to wait for. Since the style is now only enqueued from admin-header.php, this check also leaves out iframe screens, so the `IFRAME_REQUEST` check is removed. It missed iframe screens that do not define that constant, such as the theme-information modal. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 11 ++++++----- .../wpPrintViewTransitionsRenderBlockingLink.php | 15 +++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index b00b5a823b2e1..c3e023cd6a074 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -49,17 +49,18 @@ function wp_get_view_transitions_admin_css(): string { * query limits the first-render delay to when view transitions actually run, matching the * `@view-transition` rule in view-transitions.css. * - * Nothing is printed when the admin View Transitions style is not enqueued, since there is then - * no transition to wait for. Nothing is printed for iframe requests either, such as Thickbox - * modals: they have no admin menu, and an `expect` link whose target never appears blocks the - * first render until the whole document has been parsed. + * Nothing is printed unless the admin View Transitions style has already been printed, since + * there is otherwise no transition to wait for. That style is only enqueued from + * wp-admin/admin-header.php, so this also leaves out iframe screens such as Thickbox modals, + * which have no `#wpcontent`: an `expect` link whose target never appears blocks the first + * render until the whole document has been parsed. * * @since 7.2.0 * * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect */ function wp_print_view_transitions_render_blocking_link(): void { - if ( ! wp_style_is( 'wp-view-transitions-admin' ) || defined( 'IFRAME_REQUEST' ) ) { + if ( ! wp_style_is( 'wp-view-transitions-admin', 'done' ) ) { return; } diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index 0af2d5ff5bb12..46118bd2f09a5 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -50,6 +50,9 @@ public function test_hook(): void { */ public function test_prints_render_blocking_link(): void { wp_enqueue_view_transitions_admin_css(); + get_echo( 'wp_print_styles', array( 'wp-view-transitions-admin' ) ); + $this->assertTrue( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to have been printed.' ); + $output = get_echo( 'wp_print_view_transitions_render_blocking_link' ); $processor = new WP_HTML_Tag_Processor( $output ); @@ -72,16 +75,16 @@ public function test_prints_nothing_when_style_not_enqueued(): void { } /** - * Tests that nothing is printed for an iframe request, which has no admin menu. + * Tests that nothing is printed when the admin View Transitions style is enqueued but not yet printed. * - * @ticket 65032 + * This happens when the style is enqueued too late for the head, so it is printed in the footer instead. * - * @runInSeparateProcess - * @preserveGlobalState disabled + * @ticket 65032 */ - public function test_prints_nothing_for_iframe_request(): void { - define( 'IFRAME_REQUEST', true ); + public function test_prints_nothing_when_style_not_printed(): void { wp_enqueue_view_transitions_admin_css(); + $this->assertTrue( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to be enqueued.' ); + $this->assertFalse( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to not be printed yet.' ); $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); } From b8ee1e3237be3d40536b56abd824932af10cb8fc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 20:57:16 -0700 Subject: [PATCH 11/17] Reference `admin_enqueue_scripts` with `{@see}` in the docblock. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index c3e023cd6a074..5f50a7402a19d 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -11,10 +11,10 @@ * Enqueues View Transitions CSS for the admin. * * To disable admin View Transitions, dequeue the `wp-view-transitions-admin` style at the - * `admin_enqueue_scripts` action, rather than unhooking this function. + * {@see 'admin_enqueue_scripts'} action, rather than unhooking this function. * * @since 7.0.0 - * @since 7.2.0 Called from wp-admin/admin-header.php instead of hooked to `admin_enqueue_scripts`. + * @since 7.2.0 Called from wp-admin/admin-header.php instead of hooked to {@see 'admin_enqueue_scripts'}. */ function wp_enqueue_view_transitions_admin_css(): void { wp_enqueue_style( 'wp-view-transitions-admin' ); From 426336ba0fe4e20aa82830db10473004ee742075 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 21:05:15 -0700 Subject: [PATCH 12/17] Clarify that the expect link's media query does not detect a transition. The docblock said the media query limits the first-render delay to when view transitions actually run, but `prefers-reduced-motion` only reflects a motion preference. A reload or a direct visit is also held until `#wpcontent` is parsed, which is negligible since that element starts right after the admin menu. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/view-transitions.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 5f50a7402a19d..a6323098b1e85 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -46,8 +46,10 @@ function wp_get_view_transitions_admin_css(): string { * right after the admin menu rather than the menu itself: once `#wpcontent` starts, the whole * menu has been parsed. Targeting an element near the end of the body would also work, but it * would delay the first render by the time it takes to parse all of the page content. The media - * query limits the first-render delay to when view transitions actually run, matching the - * `@view-transition` rule in view-transitions.css. + * query limits the first-render delay to when view transitions can run, matching the + * `@view-transition` rule in view-transitions.css. It does not detect whether a transition is + * actually in progress, so a reload or a direct visit is also held until `#wpcontent` is parsed, + * but since that element starts right after the admin menu, the delay is negligible. * * Nothing is printed unless the admin View Transitions style has already been printed, since * there is otherwise no transition to wait for. That style is only enqueued from From 3e992c6ad6ccc15df3765dc4127b4f6d6028d53b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 21:05:24 -0700 Subject: [PATCH 13/17] Tests: Cover that iframe_header() prints no View Transitions style or expect link. iframe_header() fires the same enqueue and head actions as admin-header.php, so this guards against the enqueue being hooked to one of those actions again, which would print an expect link whose target never appears on iframe screens. The test fails when the `admin_enqueue_scripts` hook is restored. Co-Authored-By: Claude Opus 5.5 --- ...PrintViewTransitionsRenderBlockingLink.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index 46118bd2f09a5..29875bc76b5af 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -88,4 +88,33 @@ public function test_prints_nothing_when_style_not_printed(): void { $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); } + + /** + * Tests that iframe screens, which have no admin menu, get neither the style nor the expect link. + * + * iframe_header() fires the same enqueue and head actions as wp-admin/admin-header.php, so + * hooking the enqueue to one of those actions would print a link whose target never appears. + * + * @ticket 65032 + * + * @covers ::iframe_header + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_iframe_header_prints_no_style_or_link(): void { + require_once ABSPATH . 'wp-admin/includes/template.php'; + set_current_screen( 'media-upload' ); + + $output = get_echo( 'iframe_header' ); + + $this->assertTrue( did_action( 'admin_head' ) > 0, 'Expected iframe_header() to fire the admin_head action.' ); + $this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' ); + $this->assertStringNotContainsString( '@view-transition', $output, 'Expected no view transitions CSS to be printed.' ); + + $processor = new WP_HTML_Tag_Processor( $output ); + while ( $processor->next_tag( 'LINK' ) ) { + $this->assertNotSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected no expect link to be printed.' ); + } + } } From ad67e1e781a196a3272386ba86340c197dd492c9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 21:49:16 -0700 Subject: [PATCH 14/17] Fade out an open admin menu flyout instead of clipping it. The `overflow: clip` on each menu-top's image pair also clipped a flyout submenu that was open on hover or focus when navigating, since the flyout extends outside its menu-top box. Clicking a flyout link, a common way to navigate the admin, made the flyout vanish on the first frame of the transition. Give an open flyout of a non-current item its own view transition name, so it is captured outside the clipped menu-top and fades out. The current item's submenu is in the normal flow and stays with its menu-top. `match-element` generates a unique name for each flyout, so several open at once cannot collide and abort the transition. Browsers without `match-element` ignore the declaration and keep the clipped behavior. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/css/view-transitions.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/wp-admin/css/view-transitions.css b/src/wp-admin/css/view-transitions.css index 619a74be8c660..2c1d49fda1f7c 100644 --- a/src/wp-admin/css/view-transitions.css +++ b/src/wp-admin/css/view-transitions.css @@ -39,4 +39,17 @@ ::view-transition-image-pair(.wp-menu-top) { overflow: clip; } + + /* + * Capture an open flyout submenu — one shown on hover or focus for an item + * that is not the current one — as its own group, so that it fades out + * instead of being cut off by the clip above, since it extends outside its + * menu-top box. The current item's submenu is in the normal flow, so it is + * left to the clipped menu-top. match-element gives each flyout a unique + * name, so two open at once cannot collide and abort the transition. + */ + #adminmenu > .wp-not-current-submenu.opensub > .wp-submenu, + #adminmenu > .wp-not-current-submenu > a.menu-top:focus + .wp-submenu { + view-transition-name: match-element; + } } From cf51179b9dda4e65f68fc3e903b1521f29a7beff Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 21:57:16 -0700 Subject: [PATCH 15/17] Fade out flyouts in the folded admin menu as well. When the menu is folded, either explicitly or automatically between 783 and 960 pixels wide, every submenu is a flyout, including the current item's, so its flyout was still cut off by the menu-top clip. Name those flyouts too. Limit all of the flyout rules to 783 pixels and wider. Below that the menu is responsive and its submenus are in the normal flow, so naming one would pull it out of its clipped menu-top. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/css/view-transitions.css | 34 ++++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/wp-admin/css/view-transitions.css b/src/wp-admin/css/view-transitions.css index 2c1d49fda1f7c..ab0645e86a635 100644 --- a/src/wp-admin/css/view-transitions.css +++ b/src/wp-admin/css/view-transitions.css @@ -41,15 +41,31 @@ } /* - * Capture an open flyout submenu — one shown on hover or focus for an item - * that is not the current one — as its own group, so that it fades out - * instead of being cut off by the clip above, since it extends outside its - * menu-top box. The current item's submenu is in the normal flow, so it is - * left to the clipped menu-top. match-element gives each flyout a unique - * name, so two open at once cannot collide and abort the transition. + * Capture an open flyout submenu — one shown on hover or focus — as its own + * group, so that it fades out instead of being cut off by the clip above, + * since it extends outside its menu-top box. match-element gives each + * flyout a unique name, so two open at once cannot collide and abort the + * transition. + * + * In the expanded menu, the current item's submenu is in the normal flow, + * so it is left to the clipped menu-top. When the menu is folded, either + * explicitly or automatically on narrower screens, every submenu is a + * flyout, including the current one. Below 783px the menu is responsive and + * its submenus are in the normal flow, so none are named there. */ - #adminmenu > .wp-not-current-submenu.opensub > .wp-submenu, - #adminmenu > .wp-not-current-submenu > a.menu-top:focus + .wp-submenu { - view-transition-name: match-element; + @media screen and (min-width: 783px) { + #adminmenu > .wp-not-current-submenu.opensub > .wp-submenu, + #adminmenu > .wp-not-current-submenu > a.menu-top:focus + .wp-submenu, + .folded #adminmenu > .opensub > .wp-submenu, + .folded #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { + view-transition-name: match-element; + } + } + + @media screen and (min-width: 783px) and (max-width: 960px) { + .auto-fold #adminmenu > .opensub > .wp-submenu, + .auto-fold #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { + view-transition-name: match-element; + } } } From 6eeb4edfd7dc1480acbd64119371223dac958dcf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 22 Oct 2026 23:12:16 -0700 Subject: [PATCH 16/17] Move an open admin menu flyout with its menu item as it fades out. Giving an open flyout its own view transition name kept it from being clipped, but as an independent group that exists only on the outgoing page it faded out in place, staying behind while its menu item moved to its new position. Before the flyout was named, it was part of its menu item's snapshot and moved along with it. Nest the flyout's group inside its menu item's group with `view-transition-group: nearest`, so it moves with the menu item again while fading out. The nested group is a sibling of the menu item's clipped image pair, so it is still not cut off. Browsers without nested view transition groups ignore the declaration and fade it out in place. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/css/view-transitions.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/css/view-transitions.css b/src/wp-admin/css/view-transitions.css index ab0645e86a635..78adf39075692 100644 --- a/src/wp-admin/css/view-transitions.css +++ b/src/wp-admin/css/view-transitions.css @@ -45,7 +45,10 @@ * group, so that it fades out instead of being cut off by the clip above, * since it extends outside its menu-top box. match-element gives each * flyout a unique name, so two open at once cannot collide and abort the - * transition. + * transition. view-transition-group nests the flyout's group inside its + * menu-top's group, so it moves along with its menu item while fading out + * instead of staying behind at its old position. Since the nested group is + * a sibling of the menu-top's image pair, it is not cut off by the clip. * * In the expanded menu, the current item's submenu is in the normal flow, * so it is left to the clipped menu-top. When the menu is folded, either @@ -59,6 +62,7 @@ .folded #adminmenu > .opensub > .wp-submenu, .folded #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { view-transition-name: match-element; + view-transition-group: nearest; } } @@ -66,6 +70,7 @@ .auto-fold #adminmenu > .opensub > .wp-submenu, .auto-fold #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { view-transition-name: match-element; + view-transition-group: nearest; } } } From 6c9e0631c498dde99861252067775cfe87844149 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 23 Oct 2026 09:17:23 -0700 Subject: [PATCH 17/17] Tests: Remove View Transitions tests for a hook that no longer exists. The admin View Transitions style is now enqueued from wp-admin/admin-header.php rather than from a hook, so asserting that it is not hooked to `admin_enqueue_scripts` tests something that is never added. The iframe_header() test guarded against the same thing at the cost of a separate process, which accounted for most of the group's runtime. Co-Authored-By: Claude Opus 5.5 --- .../wpEnqueueViewTransitionsAdminCss.php | 12 -------- ...PrintViewTransitionsRenderBlockingLink.php | 29 ------------------- 2 files changed, 41 deletions(-) diff --git a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php index 1f112a255ff8a..2ff70a2bfdc16 100644 --- a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php +++ b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php @@ -28,18 +28,6 @@ public function tear_down() { parent::tear_down(); } - /** - * Tests that admin view transitions CSS is not enqueued via a hook. - * - * It is enqueued from wp-admin/admin-header.php instead, so iframe screens without the admin menu do not get it. - * - * @ticket 64470 - * @ticket 65032 - */ - public function test_not_hooked() { - $this->assertFalse( has_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) ); - } - /** * Tests that the admin view transitions style handle includes the inline CSS. * diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php index 29875bc76b5af..46118bd2f09a5 100644 --- a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -88,33 +88,4 @@ public function test_prints_nothing_when_style_not_printed(): void { $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); } - - /** - * Tests that iframe screens, which have no admin menu, get neither the style nor the expect link. - * - * iframe_header() fires the same enqueue and head actions as wp-admin/admin-header.php, so - * hooking the enqueue to one of those actions would print a link whose target never appears. - * - * @ticket 65032 - * - * @covers ::iframe_header - * - * @runInSeparateProcess - * @preserveGlobalState disabled - */ - public function test_iframe_header_prints_no_style_or_link(): void { - require_once ABSPATH . 'wp-admin/includes/template.php'; - set_current_screen( 'media-upload' ); - - $output = get_echo( 'iframe_header' ); - - $this->assertTrue( did_action( 'admin_head' ) > 0, 'Expected iframe_header() to fire the admin_head action.' ); - $this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' ); - $this->assertStringNotContainsString( '@view-transition', $output, 'Expected no view transitions CSS to be printed.' ); - - $processor = new WP_HTML_Tag_Processor( $output ); - while ( $processor->next_tag( 'LINK' ) ) { - $this->assertNotSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected no expect link to be printed.' ); - } - } }