From 3a370677bf4fe1b8df48e0dd45c394d7d1f2f2c3 Mon Sep 17 00:00:00 2001 From: Kuber Gupta Date: Wed, 9 Sep 2026 22:58:54 +0530 Subject: [PATCH 1/5] Popover: keep the will-change hint only while repositioning Drop the blanket `is-positioned` reset and toggle the hint from the positioning effect instead, so the popover only occupies a compositing layer while it is actually moving. --- packages/components/CHANGELOG.md | 1 + packages/components/src/popover/index.tsx | 29 +++++++++++++++++++ .../src/popover/test/index.browser.test.tsx | 21 ++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 01af713cac659b..d898629b74c591 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -11,6 +11,7 @@ ### Bug Fixes +- `Popover`: Keep the `will-change: transform` hint only while the popover is moving, so its content is no longer left on a compositing layer that Chrome can render blurry ([#77359](https://github.com/WordPress/gutenberg/pull/77359)). - `Text`: Make single-line truncation and `isBlock` overrides consistent across the main document and iframes ([#82573](https://github.com/WordPress/gutenberg/pull/82573)). - `CardDivider`: Apply the Card's width, display, and border color consistently inside iframes ([#82577](https://github.com/WordPress/gutenberg/pull/82577)). diff --git a/packages/components/src/popover/index.tsx b/packages/components/src/popover/index.tsx index 2cec20aa2c7354..9cff7dc0a76b19 100644 --- a/packages/components/src/popover/index.tsx +++ b/packages/components/src/popover/index.tsx @@ -63,6 +63,13 @@ export const SLOT_NAME = 'Popover'; * @type {number} */ const OVERFLOW_PADDING = 8; +/** + * How long to wait after the last position update before dropping the + * `will-change` hint. + * + * @type {number} + */ +const WILL_CHANGE_IDLE_TIMEOUT = 200; // An SVG displaying a triangle facing down, filled with a solid // color and bordered in such a way to create an arrow-like effect. // Keeping the SVG's viewbox squared simplify the arrow positioning @@ -392,12 +399,34 @@ const UnforwardedPopover = ( refs, ] ); + const popoverElementRef = useRef< HTMLElement | null >( null ); + const mergedFloatingRef = useMergeRefs( [ refs.setFloating, dialogRef, + popoverElementRef, forwardedRef, ] ); + // Keep the `will-change: transform` hint on only while the popover is + // actually moving. Leaving it on for good keeps the popover on its own + // compositing layer, which Chrome can render blurry, while never hinting + // makes the popover repaint on every scroll frame (see #46187). + useLayoutEffect( () => { + const popoverElement = popoverElementRef.current; + if ( ! popoverElement ) { + return; + } + + popoverElement.style.willChange = 'transform'; + + const timeoutId = setTimeout( () => { + popoverElement.style.willChange = 'auto'; + }, WILL_CHANGE_IDLE_TIMEOUT ); + + return () => clearTimeout( timeoutId ); + }, [ x, y ] ); + const style = isExpanded ? undefined : { diff --git a/packages/components/src/popover/test/index.browser.test.tsx b/packages/components/src/popover/test/index.browser.test.tsx index c919808fc338ca..d5a76f6cf1ba91 100644 --- a/packages/components/src/popover/test/index.browser.test.tsx +++ b/packages/components/src/popover/test/index.browser.test.tsx @@ -10,6 +10,7 @@ import { placementToMotionAnimationProps, } from '../utils'; import Popover from '..'; +import '../style.scss'; import { Provider as SlotFillProvider } from '../../slot-fill'; import type { PopoverProps } from '../types'; import { PopoverInsideIframeRenderedInExternalSlot } from './utils/index.js'; @@ -843,6 +844,26 @@ describe( 'Popover', () => { } ); } ); + it( 'hints at transform only while it is being repositioned', async () => { + await render( + + Inside popover + + ); + + const popover = screen.getByTestId( 'popover-element' ); + + // Positioning has just run, so the compositing hint is on. + expect( getComputedStyle( popover ).willChange ).toBe( 'transform' ); + + // Once the popover settles, the hint is dropped so the popover stops + // occupying a compositing layer of its own, which is what makes + // Chrome render it blurry. + await waitFor( () => + expect( getComputedStyle( popover ).willChange ).toBe( 'auto' ) + ); + } ); + it( 'should call a consumer-provided onKeyDown alongside close-on-Escape', async () => { const user = userEvent.setup(); const onClose = vi.fn(); From 870e6eb518712a43b890832c78c878dd5cb0c65a Mon Sep 17 00:00:00 2001 From: Kuber Gupta Date: Fri, 25 Sep 2026 14:46:24 +0530 Subject: [PATCH 2/5] Popover: restart the will-change timer when the floating element changes --- packages/components/src/popover/index.tsx | 8 +++-- .../src/popover/test/index.browser.test.tsx | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/components/src/popover/index.tsx b/packages/components/src/popover/index.tsx index 9cff7dc0a76b19..db26234bc9d653 100644 --- a/packages/components/src/popover/index.tsx +++ b/packages/components/src/popover/index.tsx @@ -337,6 +337,8 @@ const UnforwardedPopover = ( y, // Object with "regular" refs to both "reference" and "floating" refs, + // The "reference" and "floating" DOM elements + elements, // Type of CSS position property to use (absolute or fixed) strategy, update, @@ -411,7 +413,9 @@ const UnforwardedPopover = ( // Keep the `will-change: transform` hint on only while the popover is // actually moving. Leaving it on for good keeps the popover on its own // compositing layer, which Chrome can render blurry, while never hinting - // makes the popover repaint on every scroll frame (see #46187). + // makes the popover repaint on every scroll frame (see #46187). The + // floating element can also be swapped without moving, e.g. when a + // `Popover.Slot` mounts, so a new element restarts the timer too. useLayoutEffect( () => { const popoverElement = popoverElementRef.current; if ( ! popoverElement ) { @@ -425,7 +429,7 @@ const UnforwardedPopover = ( }, WILL_CHANGE_IDLE_TIMEOUT ); return () => clearTimeout( timeoutId ); - }, [ x, y ] ); + }, [ x, y, elements.floating ] ); const style = isExpanded ? undefined diff --git a/packages/components/src/popover/test/index.browser.test.tsx b/packages/components/src/popover/test/index.browser.test.tsx index d5a76f6cf1ba91..d533f789df10e2 100644 --- a/packages/components/src/popover/test/index.browser.test.tsx +++ b/packages/components/src/popover/test/index.browser.test.tsx @@ -10,6 +10,7 @@ import { placementToMotionAnimationProps, } from '../utils'; import Popover from '..'; +// eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports import '../style.scss'; import { Provider as SlotFillProvider } from '../../slot-fill'; import type { PopoverProps } from '../types'; @@ -864,6 +865,37 @@ describe( 'Popover', () => { ); } ); + it( 'drops the transform hint when the popover moves into a slot without repositioning', async () => { + const Test = ( { hasSlot }: { hasSlot: boolean } ) => ( + + + Inside popover + + { hasSlot && } + + ); + const { rerender } = await render( ); + + const inlinePopover = screen.getByTestId( 'popover-element' ); + await waitFor( () => + expect( getComputedStyle( inlinePopover ).willChange ).toBe( + 'auto' + ) + ); + + // Mounting the slot swaps in a new floating element at the same + // coordinates, and that one has to lose the hint too. + await rerender( ); + + const slottedPopover = screen.getByTestId( 'popover-element' ); + expect( slottedPopover ).not.toBe( inlinePopover ); + await waitFor( () => + expect( getComputedStyle( slottedPopover ).willChange ).toBe( + 'auto' + ) + ); + } ); + it( 'should call a consumer-provided onKeyDown alongside close-on-Escape', async () => { const user = userEvent.setup(); const onClose = vi.fn(); From a375761f693c8d77b7ec98d05aa643b3155a6bc6 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Fri, 2 Oct 2026 20:31:43 +0900 Subject: [PATCH 3/5] Popover: remove eslint-disable comment made obsolete by #84019 Co-Authored-By: Claude --- packages/components/src/popover/test/index.browser.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/popover/test/index.browser.test.tsx b/packages/components/src/popover/test/index.browser.test.tsx index d533f789df10e2..75a1d967231ec6 100644 --- a/packages/components/src/popover/test/index.browser.test.tsx +++ b/packages/components/src/popover/test/index.browser.test.tsx @@ -10,7 +10,6 @@ import { placementToMotionAnimationProps, } from '../utils'; import Popover from '..'; -// eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports import '../style.scss'; import { Provider as SlotFillProvider } from '../../slot-fill'; import type { PopoverProps } from '../types'; From c57786d13ed2ac5a29ca2b21ba6ff65ee3fc9413 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Fri, 2 Oct 2026 21:20:42 +0900 Subject: [PATCH 4/5] Popover: test that moving a settled popover restores the will-change hint until it stops Co-Authored-By: Claude --- .../src/popover/test/index.browser.test.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/components/src/popover/test/index.browser.test.tsx b/packages/components/src/popover/test/index.browser.test.tsx index 75a1d967231ec6..4c29acdb67752e 100644 --- a/packages/components/src/popover/test/index.browser.test.tsx +++ b/packages/components/src/popover/test/index.browser.test.tsx @@ -864,6 +864,61 @@ describe( 'Popover', () => { ); } ); + it( 'hints at transform again when a settled popover moves, until it stops moving', async () => { + const Test = ( { mainAxis }: { mainAxis: number } ) => ( + + Inside popover + + ); + const { rerender } = await render( ); + + const popover = screen.getByTestId( 'popover-element' ); + await waitFor( () => + expect( getComputedStyle( popover ).willChange ).toBe( 'auto' ) + ); + + vi.useFakeTimers( { toFake: [ 'setTimeout', 'clearTimeout' ] } ); + try { + const moveTo = async ( mainAxis: number ) => { + const { left } = popover.getBoundingClientRect(); + await rerender( ); + // `waitFor` stalls on the fake timers and `vi.waitFor` advances + // them, so poll on real timers instead. + await expect + .poll( () => popover.getBoundingClientRect().left ) + .toBeCloseTo( left + 10 ); + }; + + // Moving the settled popover brings the hint back. + await moveTo( 10 ); + expect( getComputedStyle( popover ).willChange ).toBe( + 'transform' + ); + + // Moving again before the idle timeout keeps the hint on past the + // first timeout. + vi.advanceTimersByTime( 150 ); + await moveTo( 20 ); + vi.advanceTimersByTime( 150 ); + expect( getComputedStyle( popover ).willChange ).toBe( + 'transform' + ); + + // Once the popover stops moving, the hint is dropped. + vi.advanceTimersByTime( 50 ); + expect( getComputedStyle( popover ).willChange ).toBe( 'auto' ); + } finally { + vi.useRealTimers(); + } + } ); + it( 'drops the transform hint when the popover moves into a slot without repositioning', async () => { const Test = ( { hasSlot }: { hasSlot: boolean } ) => ( From 75c6f4ba8ca04b1a01e1460c26499a2637a91148 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Sat, 3 Oct 2026 20:52:05 +0900 Subject: [PATCH 5/5] Popover: remove static will-change rule now that the hint is toggled while moving Co-Authored-By: Claude --- packages/components/src/popover/style.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/components/src/popover/style.scss b/packages/components/src/popover/style.scss index 947c35baae34c1..e7010e54b9f57c 100644 --- a/packages/components/src/popover/style.scss +++ b/packages/components/src/popover/style.scss @@ -13,7 +13,6 @@ $shadow-popover-border-top-only-alternate: 0 #{-$border-width} 0 $gray-900; .components-popover { @include reset; z-index: z-index(".components-popover"); - will-change: transform; &.is-expanded { position: fixed;