From 2ef468ef767386d1cb5ca2eeb088ff138e8f892e Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Fri, 5 Jun 2026 12:05:28 -0700 Subject: [PATCH] test: reset `pointer-events` before each Storybook story (#26081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes DEVEX-402 Alternative to #26077, which aimed to address this flake in UsersPage.stories.tsx only: >Unable to perform pointer interaction as the element has `pointer-events: none` In the original PR I was going to "fix" the flake by adding `pointerEventsCheck: Never` like we have in 2 other tests. However, resetting `pointer-events` during `beforeEach` in our global Vitest setup removes the need for that `pointerEventsCheck` workaround. 🙂 tl;dr: Each of these 3 tests were failing because of an immediately preceding test which opens a Radix Dialog, setting `pointer-events: none` on the body element. Co-written with Coder Agents. Relevant chat responses: >**The actual root cause is story isolation.** The previous story (`UpdateUserRoleSuccess`) opens a Radix Dialog, which triggers Radix's `DismissableLayer` to set `document.body.style.pointerEvents = "none"`. When that story completes, Storybook unmounts the React tree, but the cleanup of `body.style.pointerEvents` is tied to a `useEffect` cleanup in `DismissableLayer`. If Storybook begins the next story's play function before the browser has fully flushed the previous unmount cycle, `document.body.style.pointerEvents` is still `"none"`. The "Open menu" button, which is a plain table button with no Radix modal layer above it providing `pointer-events: auto`, inherits `"none"` from body, and the first click fails. > >**Why it's flaky:** The Storybook Vitest plugin (`@storybook/addon-vitest`) runs stories sequentially in the same browser page. When `UpdateUserRoleSuccess` finishes, React unmounts the tree, and `DismissableLayer`'s `useEffect` cleanup should restore `body.pointerEvents`. But there's a race between unmount cleanup completing and the next story's play function starting. Sometimes cleanup wins (test passes), sometimes the play function wins (test fails). > >**[#26077]'s `pointerEventsCheck: Never`** works but is a workaround. It doesn't address the leaked state, and `UpdateUserRoleSuccess` itself could be flaky too (if whatever story precedes it leaves the same stale state). The other stories in the same file (Suspend, Delete, Activate, ResetPassword) all open Radix Dialogs too, so any of them could leak this state to the next story. --- site/.storybook/vitest.setup.ts | 10 +++++++++- .../dashboard/Navbar/MobileMenu.stories.tsx | 16 +++------------ .../BatchUpdateModalForm.stories.tsx | 20 +------------------ 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/site/.storybook/vitest.setup.ts b/site/.storybook/vitest.setup.ts index b2d3a795c3f..f11a4e41f52 100644 --- a/site/.storybook/vitest.setup.ts +++ b/site/.storybook/vitest.setup.ts @@ -1,7 +1,15 @@ import { setProjectAnnotations } from "@storybook/react-vite"; -import { beforeAll } from "vitest"; +import { beforeAll, beforeEach } from "vitest"; import * as previewAnnotations from "./preview"; const annotations = setProjectAnnotations([previewAnnotations]); beforeAll(annotations.beforeAll); + +// Radix DismissableLayer sets document.body.style.pointerEvents = "none" while +// a modal layer is active. When a story unmounts, the useEffect cleanup that +// restores body.pointerEvents can race with the next story's play function, +// causing false "pointer-events: none" failures on the first click. +beforeEach(() => { + document.body.style.pointerEvents = ""; +}); diff --git a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx index 06091dac4c5..2a40a5127e2 100644 --- a/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx +++ b/site/src/modules/dashboard/Navbar/MobileMenu.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PointerEventsCheckLevel } from "@testing-library/user-event"; import type { FC } from "react"; import { fn, userEvent, within } from "storybook/test"; import { @@ -96,7 +95,7 @@ export const Member: Story = { export const ProxySettings: Story = { play: async ({ canvasElement }) => { - const user = setupUser(); + const user = userEvent.setup(); const body = within(canvasElement.ownerDocument.body); const menuItem = await body.findByRole("menuitem", { name: /workspace proxy settings/i, @@ -107,7 +106,7 @@ export const ProxySettings: Story = { export const UserSettings: Story = { play: async ({ canvasElement }) => { - const user = setupUser(); + const user = userEvent.setup(); const body = within(canvasElement.ownerDocument.body); const menuItem = await body.findByRole("menuitem", { name: /user settings/i, @@ -124,21 +123,12 @@ function withNavbarMock(Story: FC) { ); } -function setupUser() { - // It seems the dropdown component is disabling pointer events, which is - // causing Testing Library to throw an error. As a workaround, we can - // disable the pointer events check. - return userEvent.setup({ - pointerEventsCheck: PointerEventsCheckLevel.Never, - }); -} - async function openAdminSettings({ canvasElement, }: { canvasElement: HTMLElement; }) { - const user = setupUser(); + const user = userEvent.setup(); const body = within(canvasElement.ownerDocument.body); const menuItem = await body.findByRole("menuitem", { name: /admin settings/i, diff --git a/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx index 02835883cab..779f5524320 100644 --- a/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx +++ b/site/src/pages/WorkspacesPage/BatchUpdateModalForm.stories.tsx @@ -210,25 +210,7 @@ export const RunningWorkspacesFailedValidation: Story = { ); const updateButton = modal.getByRole("button", { name: "Update" }); - await userEvent.click(updateButton, { - /** - * @todo 2025-07-15 - Something in the test setup is causing the - * Update button to get treated as though it should opt out of - * pointer events, which causes userEvent to break. All of our code - * seems to be fine - we do have logic to disable pointer events, - * but only when the button is obviously configured wrong (e.g., - * it's configured as a link but has no URL). - * - * Disabling this check makes things work again, but shoots our - * confidence for how accessible the UI is, even if we know that at - * this point, the button exists, has the right text content, and is - * not disabled. - * - * We should aim to remove this property as soon as possible, - * opening up an issue upstream if necessary. - */ - pointerEventsCheck: 0, - }); + await userEvent.click(updateButton); await modal.findByText("Please acknowledge risks to continue."); const checkbox = modal.getByRole("checkbox", {