From bef39b9132d9f3bd1c9ac30b69e29991fe42b178 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 3 Apr 2026 09:15:54 -0400 Subject: [PATCH 1/4] fix(settings): invalidate cache in notification settings (#112161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some notification settings were not updating the underlying data on success, leading to the form being reset with stale `initialData`. In effect, the form appeared to not trigger any update from the frontend—a hard refresh would display the correct values because the backend did process the update. Closes DE-1043, closes #112143 --------- Co-authored-by: Dominik Dorfmeister 🔮 --- .../notifications/notificationSettings.tsx | 12 ++++++-- .../notificationSettingsByType.tsx | 28 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/static/app/views/settings/account/notifications/notificationSettings.tsx b/static/app/views/settings/account/notifications/notificationSettings.tsx index b3cbbdc5eab131..ea0c6caf012b25 100644 --- a/static/app/views/settings/account/notifications/notificationSettings.tsx +++ b/static/app/views/settings/account/notifications/notificationSettings.tsx @@ -1,6 +1,6 @@ import {Fragment} from 'react'; import styled from '@emotion/styled'; -import {mutationOptions} from '@tanstack/react-query'; +import {mutationOptions, useQueryClient} from '@tanstack/react-query'; import {z} from 'zod'; import {LinkButton} from '@sentry/scraps/button'; @@ -17,7 +17,7 @@ import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; import {t, tct} from 'sentry/locale'; import type {Organization} from 'sentry/types/organization'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {fetchMutation, useApiQuery} from 'sentry/utils/queryClient'; +import {fetchMutation, setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; import {withOrganizations} from 'sentry/utils/withOrganizations'; import type {NotificationSettingsType} from 'sentry/views/settings/account/notifications/constants'; import { @@ -45,6 +45,7 @@ interface NotificationSettingsProps { } function NotificationSettings({organizations}: NotificationSettingsProps) { + const queryClient = useQueryClient(); const checkFeatureFlag = (flag: string) => { return organizations.some(org => org.features?.includes(flag)); }; @@ -102,8 +103,13 @@ function NotificationSettings({organizations}: NotificationSettingsProps) { data, }); }, - onSuccess: () => { + onSuccess: (_, variables) => { addSuccessMessage(t('Notification preferences saved')); + setApiQueryData( + queryClient, + [NOTIFICATIONS_ENDPOINT], + existing => (existing ? {...existing, ...variables} : undefined) + ); }, }); diff --git a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx index 6f5270697b632b..4e9f207b0fa789 100644 --- a/static/app/views/settings/account/notifications/notificationSettingsByType.tsx +++ b/static/app/views/settings/account/notifications/notificationSettingsByType.tsx @@ -389,7 +389,7 @@ export function NotificationSettingsByType({notificationType}: Props) { const optionMutationOptions = (fieldName: string) => mutationOptions({ mutationFn: (data: Record) => - fetchMutation({ + fetchMutation({ method: 'PUT', url: '/users/me/notification-options/', data: { @@ -399,7 +399,23 @@ export function NotificationSettingsByType({notificationType}: Props) { value: data[fieldName], }, }), - onSuccess: () => trackTuningUpdated('general'), + onSuccess: notificationOption => { + trackTuningUpdated('general'); + setApiQueryData( + queryClient, + notificationOptionsQueryKey(notificationType), + currentOptions => { + const existing = currentOptions ?? []; + const idx = existing.findIndex(opt => opt.id === notificationOption.id); + if (idx >= 0) { + return existing.map(opt => + opt.id === notificationOption.id ? notificationOption : opt + ); + } + return [...existing, notificationOption]; + } + ); + }, }); const providerChoices = ( @@ -422,6 +438,14 @@ export function NotificationSettingsByType({notificationType}: Props) { providers: data.provider, }, }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + getApiUrl('/users/$userId/notification-providers/', {path: {userId: 'me'}}), + {query: getQueryParams(notificationType)}, + ], + }); + }, }); const renderQuotaFields = () => { From 0d441d3912c9cc4326db75499cb7e4cf996521fc Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 3 Apr 2026 09:35:07 -0400 Subject: [PATCH 2/4] feat(scraps): add indeterminate loader (again) (#112138) This reverts commit `787bdf4`, which reverted #111369 due to an unintentional change to the internal DOM structure of Button. adds an indeterminate loader to our design system and hooks it up to our button ## [`IndeterminateLoader`](https://sentry-git-scraps-loader-ii.sentry.dev/stories/core/loader/) https://github.com/user-attachments/assets/312c1d5a-f4ef-4d80-9844-611529502a1d ## [` - - - ```jsx + - - - +``` + +### Busy Buttons + +Busy buttons should be used to indicate that an async action is in progress, usually connected to the `isPending` state of a query mutation. + +export function BusyDemo() { + const [busy, setBusy] = useState({cancel: false, submit: false}); + /** @param key {'cancel' | 'submit'} */ + const handleClick = key => { + setBusy(v => ({...v, [key]: true})); + setTimeout(() => { + setBusy(v => ({...v, [key]: false})); + }, 2500); + }; + return ( + + + + + ); +} + + + + +```jsx + + + + ``` ## Icon-only Buttons diff --git a/static/app/components/core/button/button.tsx b/static/app/components/core/button/button.tsx index c305a65ee421a1..621a02d06268b3 100644 --- a/static/app/components/core/button/button.tsx +++ b/static/app/components/core/button/button.tsx @@ -1,7 +1,7 @@ -import {keyframes} from '@emotion/react'; import styled from '@emotion/styled'; import {Flex} from '@sentry/scraps/layout'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; import {useSizeContext} from '@sentry/scraps/sizeContext'; import {Tooltip} from '@sentry/scraps/tooltip'; @@ -86,7 +86,7 @@ export function Button({ visibility="visible" inset={0} > - {({className}) => } + )} @@ -103,22 +103,3 @@ const StyledButton = styled('button')< >` ${p => getButtonStyles(p)} `; - -const spin = keyframes` - to { - transform: rotate(360deg); - } -`; - -const BusySpinner = styled('span')` - &::after { - content: ''; - display: block; - width: 1em; - height: 1em; - border-radius: 50%; - border: 2px solid currentColor; - border-top-color: transparent; - animation: ${spin} 0.6s linear infinite; - } -`; diff --git a/static/app/components/core/button/styles.tsx b/static/app/components/core/button/styles.tsx index 82f6c9f5bc57a0..d206c1962e593e 100644 --- a/static/app/components/core/button/styles.tsx +++ b/static/app/components/core/button/styles.tsx @@ -75,7 +75,7 @@ export function DO_NOT_USE_getButtonStyles( fontWeight: p.theme.font.weight.sans.medium, - opacity: p.busy || p.disabled ? 0.6 : undefined, + opacity: p.disabled ? 0.6 : undefined, cursor: 'pointer', '&[disabled]': { @@ -135,6 +135,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"] > span:last-child': { + overflow: 'visible', + }, + '> span:last-child': { zIndex: 1, position: 'relative', @@ -180,7 +184,7 @@ export function DO_NOT_USE_getButtonStyles( }, }, - '&:disabled, &[aria-disabled="true"]': { + '&:disabled, &[aria-disabled="true"], &[aria-busy="true"]': { '&::after': { transform: 'translateY(0px)', }, @@ -189,6 +193,10 @@ export function DO_NOT_USE_getButtonStyles( }, }, + '&[aria-busy="true"]': { + cursor: 'progress', + }, + ...(p.priority === 'link' && { transform: 'translateY(0px)', diff --git a/static/app/components/core/loader/indeterminateLoader.tsx b/static/app/components/core/loader/indeterminateLoader.tsx new file mode 100644 index 00000000000000..bf723e44a29098 --- /dev/null +++ b/static/app/components/core/loader/indeterminateLoader.tsx @@ -0,0 +1,215 @@ +import {useEffect, useRef, useState} from 'react'; +import {keyframes} from '@emotion/react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {useResizeObserver} from '@react-aria/utils'; +import {AnimatePresence, motion} from 'framer-motion'; + +import {Stack} from '@sentry/scraps/layout'; + +import {testableTransition} from 'sentry/utils/testableTransition'; + +// required to break import cycle +// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths +import {Text} from '../text/text'; + +interface IndeterminateLoaderProps extends React.HTMLAttributes { + messages?: React.ReactNode[]; + variant?: 'vibrant' | 'monochrome'; +} + +const SQUIGGLE_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='1 0 16 8'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M17 6c-4 0-4-4-8-4S5 6 1 6'/%3E%3C/svg%3E")`; + +const indeterminateSlow = keyframes` + 0% { left: -35%; right: 100%; } + 60% { left: 100%; right: -90%; } + 100% { left: 100%; right: -90%; } +`; + +const indeterminateFast = keyframes` + 0% { left: -200%; right: 100%; } + 60% { left: 107%; right: -8%; } + 100% { left: 107%; right: -8%; } +`; + +// Lerp animation timing based on track width. +// Small (~128px): 2.0s duration, 1.0s delay +// Large (~400px+): 3.2s duration, 1.6s delay +const WIDTH = {MIN: 128, MAX: 400}; +const DURATION = {MIN: 2.0, MAX: 2.8}; +const DELAY = {MIN: 0.8, MAX: 1.2}; + +function lerp(min: number, max: number, t: number): number { + return min + (max - min) * Math.min(1, Math.max(0, t)); +} + +function useAnimationTiming() { + const ref = useRef(null); + const [duration, setDuration] = useState(DURATION.MAX); + const [delay, setDelay] = useState(DELAY.MAX); + + useResizeObserver({ + ref, + onResize() { + const w = ref.current?.offsetWidth ?? WIDTH.MAX; + const t = (w - WIDTH.MIN) / (WIDTH.MAX - WIDTH.MIN); + setDuration(lerp(DURATION.MIN, DURATION.MAX, t)); + setDelay(lerp(DELAY.MIN, DELAY.MAX, t)); + }, + }); + + return {ref, duration, delay}; +} + +const MESSAGE_INTERVAL_MS = 10_000; + +function useMessageCycler(messages: React.ReactNode[]) { + const [index, setIndex] = useState(0); + + useEffect(() => { + if (messages.length <= 1 || index >= messages.length - 1) { + return undefined; + } + const timer = setTimeout(() => setIndex(i => i + 1), MESSAGE_INTERVAL_MS); + return () => clearTimeout(timer); + }, [index, messages.length]); + + return {message: messages.length > 0 ? messages[index] : null, index}; +} + +export function IndeterminateLoader({ + variant = 'vibrant', + messages, + ...props +}: IndeterminateLoaderProps) { + const theme = useTheme(); + const {ref, duration, delay} = useAnimationTiming(); + const {message: currentMessage, index: messageIndex} = useMessageCycler(messages ?? []); + + const track = ( + + + + + + + ); + + if (!messages?.length) { + return track; + } + + return ( + + {track} + + + + {currentMessage} + + + + + + ); +} + +const dotFadeInOut = keyframes` + 0%, 30% { opacity: 0; } + 40%, 70% { opacity: 1; } + 80%, 100% { opacity: 0; } +`; + +function Ellipsis() { + return ( + + . + . + . + + ); +} + +const Dot = styled('span')<{delay: number}>` + opacity: 0; + animation: ${dotFadeInOut} 2.5s ${p => p.delay}s infinite; +`; + +const Track = styled('div')<{color: string; opacity: string}>` + position: relative; + overflow: hidden; + width: 100%; + width: calc(round(down, 100% - 16px, 8px) + 16px); + height: 8px; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: ${p => p.color}; + opacity: ${p => p.opacity}; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; + } +`; + +const ColorMask = styled('span')` + position: absolute; + inset: 0; + mask-image: ${SQUIGGLE_TILE}; + mask-repeat: repeat-x; + mask-size: 16px 8px; + -webkit-mask-image: ${SQUIGGLE_TILE}; + -webkit-mask-repeat: repeat-x; + -webkit-mask-size: 16px 8px; +`; + +const Bar = styled('span')<{ + animation: ReturnType; + color: string; + delay: string; + duration: string; + timing: string; +}>` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: ${p => p.color}; + animation: ${p => p.animation} ${p => p.duration} ${p => p.timing} ${p => p.delay} + infinite backwards; +`; diff --git a/static/app/components/core/loader/index.tsx b/static/app/components/core/loader/index.tsx new file mode 100644 index 00000000000000..f763feb1695b1c --- /dev/null +++ b/static/app/components/core/loader/index.tsx @@ -0,0 +1 @@ +export {IndeterminateLoader} from './indeterminateLoader'; diff --git a/static/app/components/core/loader/loader.mdx b/static/app/components/core/loader/loader.mdx new file mode 100644 index 00000000000000..cb33feafb2e88a --- /dev/null +++ b/static/app/components/core/loader/loader.mdx @@ -0,0 +1,108 @@ +--- +title: IndeterminateLoader +description: An animated squiggle loader that fills its container to indicate indeterminate progress. +category: status +source: '@sentry/scraps/loader' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/loader/indeterminateLoader.tsx +--- + +import {Container, Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; +import {IndeterminateLoader} from '@sentry/scraps/loader'; + +import {Demo} from 'sentry/stories/demo'; + +export const documentation = import('!!type-loader!@sentry/scraps/loader'); + +The `IndeterminateLoader` renders a repeating squiggle pattern with an animated color wipe to indicate indeterminate loading progress. It fills 100% of its parent's width. + +## Usage + + + + + +```jsx + +``` + +## Messages + +Pass an array of `messages` to step through loading messages as the loader runs. + + + + + +```jsx + +``` + +## Contained Width + +Constrain the loader's width by wrapping it in a sized container. + + + + + + + + + + + + + +```jsx + + + + + + + + + +``` + +## Monochrome + +Use `variant="monochrome"` to inherit `currentColor` from the parent. The track renders at 25% opacity and the accent at full opacity. + +This is used internally in the Button component. + + + + + + + + + + + + + +```jsx + + + +``` + +## Accessibility + +The component renders with `role="progressbar"` and a default `aria-label` of `"Loading"`. Override the label to provide more specific context: + +```jsx + +``` From c3e6a7f65de66d2eb4d2499c7a64b3d069fa0ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Fri, 3 Apr 2026 10:33:26 -0400 Subject: [PATCH 3/4] fix(test): stabilize flaky EventGroupingInfo rendering test (#111906) Previously, `groupingInfoSection.spec.tsx` was testing the `GroupingInfoSection` component: but all that component does is combine the `InterimSection` component with a lazy-loaded `GroupingInfo` component. That lazy-loading can take >=100-150ms locally, and might be the cause of flake in CI. This PR changes the test to: * Directly test `GroupingInfo`, bypassing the lazy-load * Delete the specific flaky test that was checking for the component being loaded after click (since that's covered by `FoldSection`'s unit tests, as rendered by `InterimSection`) Fixes ENG-7209 ~Note that CI Jest tests are failing because the https://github.com/getsentry/sentry/labels/Frontend%3A%20Rerun%20Flaky%20Tests label is causing _other_, still-flaky tests to be run.~ Rebased on `master` so the `it.isKnownFlake` (#111860) addition isn't here yet. Made with [Cursor](https://cursor.com) Co-authored-by: @nikkikapadia --- .github/CODEOWNERS | 1 + ...Section.spec.tsx => groupingInfo.spec.tsx} | 23 ++++--------------- 2 files changed, 6 insertions(+), 18 deletions(-) rename static/app/components/events/groupingInfo/{groupingInfoSection.spec.tsx => groupingInfo.spec.tsx} (80%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40f02ebc420fd4..891c0ad90ce9e0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -345,6 +345,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /static/app/views/performance/ @getsentry/data-browsing /static/app/components/performance/ @getsentry/data-browsing /static/app/utils/performance/ @getsentry/data-browsing +/static/app/components/events/groupingInfo @getsentry/data-browsing /static/app/components/events/interfaces/spans/ @getsentry/data-browsing /static/app/components/events/viewHierarchy/* @getsentry/data-browsing /static/app/components/searchQueryBuilder/ @getsentry/data-browsing diff --git a/static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx b/static/app/components/events/groupingInfo/groupingInfo.spec.tsx similarity index 80% rename from static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx rename to static/app/components/events/groupingInfo/groupingInfo.spec.tsx index d9904f574b5403..c157c449650eeb 100644 --- a/static/app/components/events/groupingInfo/groupingInfoSection.spec.tsx +++ b/static/app/components/events/groupingInfo/groupingInfo.spec.tsx @@ -1,12 +1,12 @@ import {EventFixture} from 'sentry-fixture/event'; import {GroupFixture} from 'sentry-fixture/group'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen} from 'sentry-test/reactTestingLibrary'; import {EventGroupVariantType} from 'sentry/types/event'; import {IssueCategory} from 'sentry/types/group'; -import {EventGroupingInfoSection} from './groupingInfoSection'; +import GroupingInfo from './groupingInfo'; describe('EventGroupingInfo', () => { const group = GroupFixture(); @@ -45,15 +45,6 @@ describe('EventGroupingInfo', () => { }); }); - it('fetches and renders grouping info for errors', async () => { - render(); - await userEvent.click( - screen.getByRole('button', {name: 'View Event Grouping Information Section'}) - ); - expect(await screen.findByText('variant description')).toBeInTheDocument(); - expect(screen.getByText('123')).toBeInTheDocument(); - }); - it('gets performance grouping info from group/event data', async () => { const perfEvent = EventFixture({ type: 'transaction', @@ -61,9 +52,7 @@ describe('EventGroupingInfo', () => { }); const perfGroup = GroupFixture({issueCategory: IssueCategory.PERFORMANCE}); - render( - - ); + render(); expect(await screen.findByText('performance problem')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); @@ -88,7 +77,7 @@ describe('EventGroupingInfo', () => { }, }, }); - render(); + render(); expect(await screen.findByText('variant description')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); @@ -116,9 +105,7 @@ describe('EventGroupingInfo', () => { }); const perfGroup = GroupFixture({issueCategory: IssueCategory.PERFORMANCE}); - render( - - ); + render(); expect(await screen.findByText('performance problem')).toBeInTheDocument(); expect(screen.getByText('123')).toBeInTheDocument(); From ee3ab763fdd2a5ae777e9d017f275bc00c76e820 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 3 Apr 2026 11:25:21 -0400 Subject: [PATCH 4/4] fix(scraps): status indicator layering (#112197) Previously, the `StatusIndicator` component was rendered with the pulse on top of the dot. By splitting into separate `::before` and `::after` elements, the layering is fixed. **Before** (subtle, but accent variant is dimmed) before **After** after --- .../core/statusIndicator/statusIndicator.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/static/app/components/core/statusIndicator/statusIndicator.tsx b/static/app/components/core/statusIndicator/statusIndicator.tsx index 6ef1d4f1567094..59c0fdfcc61fb6 100644 --- a/static/app/components/core/statusIndicator/statusIndicator.tsx +++ b/static/app/components/core/statusIndicator/statusIndicator.tsx @@ -91,6 +91,18 @@ function getDotTokens( } const gentlePulse = keyframes` + 0%, 100% { + transform: scale(1); + } + 33% { + transform: scale(0.95); + } + 67% { + transform: scale(1.05); + } +`; + +const gentleSpin = keyframes` 0% { opacity: 0; border-radius: 6px; @@ -110,22 +122,28 @@ const gentlePulse = keyframes` const Dot = styled('span')<{variant: StatusIndicatorVariant}>` position: relative; - isolation: isolate; - border-radius: ${p => p.theme.radius.xs}; width: 8px; height: 8px; - background-color: ${p => getDotTokens(p.variant, p.theme).dot}; - &::before { content: ''; position: absolute; - z-index: -1; top: -2px; left: -2px; width: 12px; height: 12px; border-radius: ${p => p.theme.radius['2xs']}; background-color: ${p => getDotTokens(p.variant, p.theme).pulse}; - animation: ${gentlePulse} 2.2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; + animation: ${gentleSpin} 2.2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; + } + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 8px; + height: 8px; + border-radius: ${p => p.theme.radius.xs}; + background-color: ${p => getDotTokens(p.variant, p.theme).dot}; + animation: ${gentlePulse} 2.2s cubic-bezier(0.445, 0.05, 0.55, 0.95) infinite; } `;