From 9e9d627ee1a8e2a3061bad72dc20c8959b26da35 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 8 Jun 2026 10:36:33 -0700 Subject: [PATCH 1/4] dialog: fix `stopPropagation` on overlay blocking dismissal (#3952) --- apps/storybook/stories/dialog.stories.tsx | 49 ++++++++++ packages/react/dialog/src/dialog.tsx | 11 ++- .../src/dismissable-layer.test.tsx | 94 +++++++++++++++++++ .../src/dismissable-layer.tsx | 57 +++++++++-- packages/react/dismissable-layer/src/index.ts | 2 + 5 files changed, 203 insertions(+), 10 deletions(-) diff --git a/apps/storybook/stories/dialog.stories.tsx b/apps/storybook/stories/dialog.stories.tsx index 6b03ddef9a..2e9aeac8e7 100644 --- a/apps/storybook/stories/dialog.stories.tsx +++ b/apps/storybook/stories/dialog.stories.tsx @@ -176,6 +176,55 @@ export const NoPointerDownOutsideDismiss = () => ( ); +export const EventPropagation = () => { + const count = React.useRef(0); + const [stopPropagation, setStopPropagation] = React.useState(true); + return ( + <> +
{ + count.current++; + console.log(`clicked the card ${count.current} time${count.current === 1 ? '' : 's'}!`); + }} + > + + event.stopPropagation()}>open + + { + if (stopPropagation) { + event.stopPropagation(); + } + console.log('clicked the dialog overlay!'); + }} + /> + { + if (stopPropagation) { + event.stopPropagation(); + } + console.log('clicked the dialog content!'); + }} + > + + close + + Title + You can close me now! + + + +
+ + + ); +}; + export const WithPortalContainer = () => { const [portalContainer, setPortalContainer] = React.useState(null); return ( diff --git a/packages/react/dialog/src/dialog.tsx b/packages/react/dialog/src/dialog.tsx index f993e2e1e9..8aa78f7127 100644 --- a/packages/react/dialog/src/dialog.tsx +++ b/packages/react/dialog/src/dialog.tsx @@ -4,7 +4,7 @@ import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { createContextScope } from '@radix-ui/react-context'; import { useId } from '@radix-ui/react-id'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; -import { DismissableLayer } from '@radix-ui/react-dismissable-layer'; +import { DismissableLayer, useDismissableLayerSurface } from '@radix-ui/react-dismissable-layer'; import { FocusScope } from '@radix-ui/react-focus-scope'; import { Portal as PortalPrimitive } from '@radix-ui/react-portal'; import { Presence } from '@radix-ui/react-presence'; @@ -200,6 +200,13 @@ const DialogOverlayImpl = React.forwardRef, forwardedRef) => { const { __scopeDialog, ...overlayProps } = props; const context = useDialogContext(OVERLAY_NAME, __scopeDialog); + + // Register the overlay as a dismiss surface so a consumer calling + // `stopPropagation` on it (eg. to avoid triggering parent handlers) does not + // prevent the dialog from closing. See: https://github.com/radix-ui/primitives/issues/3346 + const registerDismissableSurface = useDismissableLayerSurface(); + const composedRefs = useComposedRefs(forwardedRef, registerDismissableSurface); + return ( // Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll` // ie. when `Overlay` and `Content` are siblings @@ -207,7 +214,7 @@ const DialogOverlayImpl = React.forwardRef diff --git a/packages/react/dismissable-layer/src/dismissable-layer.test.tsx b/packages/react/dismissable-layer/src/dismissable-layer.test.tsx index 83d6e6787b..4ad5769f49 100644 --- a/packages/react/dismissable-layer/src/dismissable-layer.test.tsx +++ b/packages/react/dismissable-layer/src/dismissable-layer.test.tsx @@ -281,6 +281,100 @@ describe('DismissableLayer', () => { expect(onDismiss).not.toHaveBeenCalled(); }); + it('dismisses when a registered dismiss surface stops propagation', async () => { + const onDismiss = vi.fn(); + + function Surface() { + const registerSurface = DismissableLayer.useDismissableLayerSurface(); + return ( +
event.stopPropagation()}> + surface +
+ ); + } + + render( + <> + + + + + , + ); + await waitForDocumentPointerDownListener(); + + firePointerMouseClick(screen.getByText('surface')); + await waitForDocumentPointerDownListener(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('dismisses when a registered dismiss surface stops propagation without deferring', async () => { + const onDismiss = vi.fn(); + + function Surface() { + const registerSurface = DismissableLayer.useDismissableLayerSurface(); + return ( +
event.stopPropagation()}> + surface +
+ ); + } + + render( + <> + + + + + , + ); + await waitForDocumentPointerDownListener(); + + firePointerMouseClick(screen.getByText('surface')); + await waitForDocumentPointerDownListener(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it('only exempts registered dismiss surfaces from stopped propagation', async () => { + const onDismiss = vi.fn(); + + function Surface() { + const registerSurface = DismissableLayer.useDismissableLayerSurface(); + return ( +
event.stopPropagation()}> + surface +
+ ); + } + + render( + <> + + + + + + , + ); + await waitForDocumentPointerDownListener(); + + // A non-surface outside element that stops propagation should still block + // dismissal. + const outside = screen.getByText('outside'); + const stopPropagation = (event: Event) => event.stopPropagation(); + outside.addEventListener('mousedown', stopPropagation); + outside.addEventListener('mouseup', stopPropagation); + outside.addEventListener('click', stopPropagation); + + firePointerMouseClick(outside); + await waitForDocumentPointerDownListener(); + expect(onDismiss).not.toHaveBeenCalled(); + + firePointerMouseClick(screen.getByText('surface')); + await waitForDocumentPointerDownListener(); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + it('does not dismiss when focus moves outside during a deferred stopped interaction', async () => { const onDismiss = vi.fn(); diff --git a/packages/react/dismissable-layer/src/dismissable-layer.tsx b/packages/react/dismissable-layer/src/dismissable-layer.tsx index eac7f33a35..fffcd08d20 100644 --- a/packages/react/dismissable-layer/src/dismissable-layer.tsx +++ b/packages/react/dismissable-layer/src/dismissable-layer.tsx @@ -20,6 +20,13 @@ const DismissableLayerContext = React.createContext({ layers: new Set(), layersWithOutsidePointerEventsDisabled: new Set(), branches: new Set(), + + // Outside elements that belong to a layer's own dismiss affordance (eg, a + // dialog overlay). Pressing them should dismiss the layer regardless of + // whether or not they stop propagation. + // + // See https://github.com/radix-ui/primitives/issues/3346 + dismissableSurfaces: new Set(), }); type DismissableLayerElement = React.ComponentRef; @@ -108,6 +115,7 @@ const DismissableLayer = React.forwardRef { + const context = React.useContext(DismissableLayerContext); + const [node, setNode] = React.useState(null); + + React.useEffect(() => { + if (!node) { + return; + } + context.dismissableSurfaces.add(node); + return () => { + context.dismissableSurfaces.delete(node); + }; + }, [node, context.dismissableSurfaces]); + + return setNode; +} + type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>; type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>; @@ -255,12 +283,14 @@ function usePointerDownOutside( ownerDocument: Document | undefined; deferPointerDownOutside: boolean; isDeferredPointerDownOutsideRef: React.RefObject; + dismissableSurfaces: Set; }, ) { const { ownerDocument = globalThis?.document, deferPointerDownOutside = false, isDeferredPointerDownOutsideRef, + dismissableSurfaces, } = args; const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener; const isPointerInsideReactTreeRef = React.useRef(false); @@ -280,16 +310,25 @@ function usePointerDownOutside( } function handleInteractionCapture(event: Event) { - if (isPointerDownOutsideRef.current) { + if (!isPointerDownOutsideRef.current) { + return; + } + + const target = event.target; + const isDismissableSurface = + target instanceof Node && + [...dismissableSurfaces].some((surface) => surface.contains(target as Node)); + + if (!isDismissableSurface) { interceptedOutsideInteractionEventsRef.current.set(event.type, true); + } - if (event.type === 'click') { - window.setTimeout(() => { - if (isPointerDownOutsideRef.current) { - handleClickRef.current(); - } - }, 0); - } + if (event.type === 'click') { + window.setTimeout(() => { + if (isPointerDownOutsideRef.current) { + handleClickRef.current(); + } + }, 0); } } @@ -407,6 +446,7 @@ function usePointerDownOutside( handlePointerDownOutside, deferPointerDownOutside, isDeferredPointerDownOutsideRef, + dismissableSurfaces, ]); return { @@ -473,6 +513,7 @@ const Branch = DismissableLayerBranch; export { DismissableLayer, DismissableLayerBranch, + useDismissableLayerSurface, // Root, Branch, diff --git a/packages/react/dismissable-layer/src/index.ts b/packages/react/dismissable-layer/src/index.ts index 596243e6e6..f53c4dcea9 100644 --- a/packages/react/dismissable-layer/src/index.ts +++ b/packages/react/dismissable-layer/src/index.ts @@ -5,5 +5,7 @@ export { // Root, Branch, + /** @internal */ + useDismissableLayerSurface, } from './dismissable-layer'; export type { DismissableLayerProps } from './dismissable-layer'; From e75d7a76ab215ad4837dcb4029a24b61af652e25 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 8 Jun 2026 13:10:56 -0700 Subject: [PATCH 2/4] password-toggle-field: fix prop type defs to include `asChild` (#3954) --- .changeset/tiny-groups-behave.md | 6 ++++++ .../password-toggle-field/src/password-toggle-field.tsx | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .changeset/tiny-groups-behave.md diff --git a/.changeset/tiny-groups-behave.md b/.changeset/tiny-groups-behave.md new file mode 100644 index 0000000000..631e413de6 --- /dev/null +++ b/.changeset/tiny-groups-behave.md @@ -0,0 +1,6 @@ +--- +"@radix-ui/react-password-toggle-field": patch +"radix-ui": patch +--- + +Fixed prop type definitions to include `asChild` for all component parts diff --git a/packages/react/password-toggle-field/src/password-toggle-field.tsx b/packages/react/password-toggle-field/src/password-toggle-field.tsx index 812cfc858e..5905171805 100644 --- a/packages/react/password-toggle-field/src/password-toggle-field.tsx +++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { flushSync } from 'react-dom'; import { composeEventHandlers } from '@radix-ui/primitive'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; -import { Primitive } from '@radix-ui/react-primitive'; +import { Primitive, type PrimitivePropsWithRef } from '@radix-ui/react-primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { useId } from '@radix-ui/react-id'; import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; @@ -102,7 +102,7 @@ PasswordToggleField.displayName = PASSWORD_TOGGLE_FIELD_NAME; const PASSWORD_TOGGLE_FIELD_INPUT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Input'; -type PrimitiveInputProps = React.ComponentPropsWithoutRef<'input'>; +type PrimitiveInputProps = PrimitivePropsWithRef<'input'>; interface PasswordToggleFieldOwnProps { autoComplete?: 'current-password' | 'new-password'; @@ -201,7 +201,7 @@ PasswordToggleFieldInput.displayName = PASSWORD_TOGGLE_FIELD_INPUT_NAME; const PASSWORD_TOGGLE_FIELD_TOGGLE_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Toggle'; -type PrimitiveButtonProps = React.ComponentPropsWithoutRef<'button'>; +type PrimitiveButtonProps = PrimitivePropsWithRef<'button'>; interface PasswordToggleFieldToggleProps extends Omit {} @@ -406,7 +406,7 @@ PasswordToggleFieldSlot.displayName = PASSWORD_TOGGLE_FIELD_SLOT_NAME; const PASSWORD_TOGGLE_FIELD_ICON_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Icon'; -type PrimitiveSvgProps = React.ComponentPropsWithoutRef<'svg'>; +type PrimitiveSvgProps = PrimitivePropsWithRef<'svg'>; interface PasswordToggleFieldIconProps extends Omit { visible: React.ReactElement; From a4fa2aa441f3edabad370d591bc0ea39f15fcfef Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 8 Jun 2026 13:14:06 -0700 Subject: [PATCH 3/4] Harden Select e2e test (#3955) --- cypress/e2e/Select.cy.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/Select.cy.ts b/cypress/e2e/Select.cy.ts index f902b252d1..2cd68670ab 100644 --- a/cypress/e2e/Select.cy.ts +++ b/cypress/e2e/Select.cy.ts @@ -94,6 +94,9 @@ describe('Select (shadow DOM)', () => { .findByLabelText(/pick a food/i) .realTouch(); + // wait for the content to be open and settled before interacting with it + cy.get('.shadow-host').shadow().findByRole('listbox').should('be.visible'); + // trigger a touch scroll, triggering the pointer move event and ensuring // we do not preventDefault on the upcoming pointer up event cy.get('.shadow-host').shadow().find('[data-radix-select-viewport]').realSwipe('toTop', { @@ -103,14 +106,23 @@ describe('Select (shadow DOM)', () => { // assert the select content is still open after swiping cy.get('.shadow-host').shadow().findByRole('listbox').should('exist'); - // select an item after scrolling + // select an item after scrolling, ensuring it is scrolled into view and + // visible so the touch reliably lands within the constrained viewport cy.get('.shadow-host') .shadow() .findByRole('option', { name: /Grapes/i }) + .scrollIntoView() + .should('be.visible') .realTouch(); - // assert the select value has been updated - cy.get('.shadow-host').shadow().findByText(/food:/).should('include.text', 'grapes'); + // selecting an item should close the content, which confirms the + // selection registered before we assert on the bound value + cy.get('.shadow-host').shadow().findByRole('listbox').should('not.exist'); + + // assert the select value has been updated. We query the element directly + // rather than running `findByText` against the shadow root, which throws a + // confusing "got ShadowRoot" error while retrying when the node is absent. + cy.get('.shadow-host').shadow().find('p').should('include.text', 'food: grapes'); }); }); }); From 164b204f6430c4b8a7f6d4a8c5f8440e6f5bd2d2 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Mon, 8 Jun 2026 13:52:05 -0700 Subject: [PATCH 4/4] scroll-area: Stabilize viewport style tag unless nonce changes (#3957) Closes #3417 --- .changeset/five-carpets-begin.md | 6 +++++ .../react/scroll-area/src/scroll-area.tsx | 23 +++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 .changeset/five-carpets-begin.md diff --git a/.changeset/five-carpets-begin.md b/.changeset/five-carpets-begin.md new file mode 100644 index 0000000000..07d896f5f2 --- /dev/null +++ b/.changeset/five-carpets-begin.md @@ -0,0 +1,6 @@ +--- +"@radix-ui/react-scroll-area": patch +"radix-ui": patch +--- + +Stabilize viewport style tag unless nonce changes. diff --git a/packages/react/scroll-area/src/scroll-area.tsx b/packages/react/scroll-area/src/scroll-area.tsx index 8272f346f7..a3ea1e9043 100644 --- a/packages/react/scroll-area/src/scroll-area.tsx +++ b/packages/react/scroll-area/src/scroll-area.tsx @@ -145,13 +145,7 @@ const ScrollAreaViewport = React.forwardRef - {/* Hide scrollbars cross-browser and enable momentum scroll for touch devices */} -