diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 85c6b7494c..dd3ce7dc92 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1941,6 +1941,7 @@ export type ToastApi = { post(toast: ToastMessage): string; close(key: string): void; toast$(): Observable; + close$(): Observable; }; // @public diff --git a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts index 4fd85e81db..a5d1c9707e 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts @@ -111,6 +111,12 @@ export type ToastApi = { * Observe toasts posted by other parts of the application. */ toast$(): Observable; + + /** + * Observe close events for programmatic toast dismissal. + * Emits the key of the toast that should be closed. + */ + close$(): Observable; }; /** diff --git a/plugins/app/package.json b/plugins/app/package.json index ed03bd8d4d..62ee98208c 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -65,13 +65,12 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@react-aria/button": "^3.13.3", "@react-aria/toast": "^3.0.9", "@react-hookz/web": "^24.0.0", + "@react-stately/toast": "^3.1.2", "@remixicon/react": "^4.6.0", "motion": "^12.0.0", - "react-aria": "^3.41.1", - "react-aria-components": "^1.14.0", - "react-stately": "^3.35.0", "react-use": "^17.2.4", "zen-observable": "^0.10.0", "zod": "^3.25.76" diff --git a/plugins/app/src/apis/ToastApiForwarder.ts b/plugins/app/src/apis/ToastApiForwarder.ts index c3fc8610b3..afae550c5a 100644 --- a/plugins/app/src/apis/ToastApiForwarder.ts +++ b/plugins/app/src/apis/ToastApiForwarder.ts @@ -88,6 +88,7 @@ export class ToastApiForwarder implements ToastApi { private readonly subject = new PublishSubject(); private readonly closeSubject = new PublishSubject(); private readonly recentToasts: ToastMessageWithKey[] = []; + private readonly closedKeys = new Set(); private readonly maxBufferSize = 10; post(toast: ToastMessage): string { @@ -104,16 +105,28 @@ export class ToastApiForwarder implements ToastApi { } close(key: string): void { + // Track closed keys to prevent replaying dismissed toasts + this.closedKeys.add(key); + // Remove from recent buffer if still there const index = this.recentToasts.findIndex(t => t.key === key); if (index !== -1) { this.recentToasts.splice(index, 1); } this.closeSubject.next(key); + + // Clean up old closed keys when buffer is cleared + if (this.recentToasts.length === 0) { + this.closedKeys.clear(); + } } toast$(): Observable { - return this.subject.asObservable(this.recentToasts); + // Filter out any toasts that were closed to handle race conditions + const activeToasts = this.recentToasts.filter( + t => !this.closedKeys.has(t.key), + ); + return this.subject.asObservable(activeToasts); } /** diff --git a/plugins/app/src/components/Toast/Toast.tsx b/plugins/app/src/components/Toast/Toast.tsx index bdab7b69a5..b910cfb095 100644 --- a/plugins/app/src/components/Toast/Toast.tsx +++ b/plugins/app/src/components/Toast/Toast.tsx @@ -24,7 +24,7 @@ import { useState, } from 'react'; import { useToast } from '@react-aria/toast'; -import { Button } from 'react-aria-components'; +import { useButton } from '@react-aria/button'; import { motion } from 'motion/react'; import { RiInformationLine, @@ -76,6 +76,9 @@ export const Toast = forwardRef( toastRef, ); + // Close button ref for useButton hook + const closeButtonRef = useRef(null); + // Extract only ARIA and accessibility props from toastProps to avoid // conflicts with motion.div's event handler types (motion has its own drag API) const ariaProps = { @@ -119,6 +122,15 @@ export const Toast = forwardRef( state.close(toast.key); }; + // Get button props from useButton hook + const { buttonProps } = useButton( + { + 'aria-label': closeButtonProps['aria-label'], + onPress: handleClose, + }, + closeButtonRef, + ); + // Get content from toast const content = toast.content; const finalStatus = status || content.status || 'info'; @@ -246,8 +258,8 @@ export const Toast = forwardRef( )} {content.links && content.links.length > 0 && (
- {content.links.map((link, idx) => ( - + {content.links.map(link => ( + {link.label} ))} @@ -255,14 +267,14 @@ export const Toast = forwardRef( )}
- + ); }, diff --git a/plugins/app/src/components/Toast/ToastContainer.tsx b/plugins/app/src/components/Toast/ToastContainer.tsx index 411122e340..07bb38e537 100644 --- a/plugins/app/src/components/Toast/ToastContainer.tsx +++ b/plugins/app/src/components/Toast/ToastContainer.tsx @@ -16,7 +16,7 @@ import { forwardRef, Ref, useState, useRef, useCallback, useMemo } from 'react'; import { useToastRegion } from '@react-aria/toast'; -import { useToastQueue } from 'react-stately'; +import { useToastQueue } from '@react-stately/toast'; import { AnimatePresence } from 'motion/react'; import type { ToastContainerProps } from './types'; import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode'; diff --git a/plugins/app/src/components/Toast/ToastDisplay.tsx b/plugins/app/src/components/Toast/ToastDisplay.tsx index e0cc10759c..02be667e35 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.tsx @@ -17,7 +17,6 @@ import { useEffect, useRef } from 'react'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import { toastApiRef } from '@backstage/frontend-plugin-api'; -import { ToastApiForwarder } from '../../apis'; import { ToastContainer } from './ToastContainer'; import { toastQueue } from './ToastQueue'; import type { ToastDisplayProps, ToastContent } from './types'; @@ -103,15 +102,9 @@ export function ToastDisplay(props: ToastDisplayProps) { return () => subscription.unsubscribe(); }, [toastApi]); - // Subscribe to ToastApi close events + // Subscribe to ToastApi close events for programmatic dismissal useEffect(() => { - // The ToastApiForwarder exposes a close$() method for programmatic close - const forwarder = toastApi as ToastApiForwarder; - if (!forwarder.close$) { - return undefined; - } - - const subscription = forwarder.close$().subscribe(apiKey => { + const subscription = toastApi.close$().subscribe(apiKey => { const queueKey = toastKeyMap.current.get(apiKey); if (queueKey) { toastQueue.close(queueKey); diff --git a/plugins/app/src/components/Toast/ToastQueue.ts b/plugins/app/src/components/Toast/ToastQueue.ts index c5f338b3f1..31ec0cb2d9 100644 --- a/plugins/app/src/components/Toast/ToastQueue.ts +++ b/plugins/app/src/components/Toast/ToastQueue.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ToastQueue } from 'react-stately'; +import { ToastQueue } from '@react-stately/toast'; import type { ToastContent } from './types'; /** diff --git a/plugins/app/src/hooks/useInvertedThemeMode.ts b/plugins/app/src/hooks/useInvertedThemeMode.ts index c57775af77..cdad328fd5 100644 --- a/plugins/app/src/hooks/useInvertedThemeMode.ts +++ b/plugins/app/src/hooks/useInvertedThemeMode.ts @@ -55,10 +55,13 @@ export function useInvertedThemeMode(): ThemeMode { attributeFilter: ['data-theme-mode'], }); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['data-theme-mode'], - }); + // Body might not exist in some edge cases (e.g., during SSR or early lifecycle) + if (document.body) { + observer.observe(document.body, { + attributes: true, + attributeFilter: ['data-theme-mode'], + }); + } return () => observer.disconnect(); }, []); diff --git a/yarn.lock b/yarn.lock index 1301c14b4d..8fbf1497d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4301,8 +4301,10 @@ __metadata: "@material-ui/core": "npm:^4.9.13" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.61" + "@react-aria/button": "npm:^3.13.3" "@react-aria/toast": "npm:^3.0.9" "@react-hookz/web": "npm:^24.0.0" + "@react-stately/toast": "npm:^3.1.2" "@remixicon/react": "npm:^4.6.0" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^16.0.0" @@ -4311,11 +4313,8 @@ __metadata: motion: "npm:^12.0.0" msw: "npm:^1.0.0" react: "npm:^18.0.2" - react-aria: "npm:^3.41.1" - react-aria-components: "npm:^1.14.0" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" - react-stately: "npm:^3.35.0" react-use: "npm:^17.2.4" zen-observable: "npm:^0.10.0" zod: "npm:^3.25.76" @@ -8121,7 +8120,6 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/version-bridge": "workspace:^" - "@react-aria/toast": "npm:^3.0.9" "@remixicon/react": "npm:^4.6.0" "@tanstack/react-table": "npm:^8.21.3" "@types/react": "npm:^18.0.0" @@ -8130,12 +8128,10 @@ __metadata: eslint-plugin-storybook: "npm:^10.3.0-alpha.1" glob: "npm:^11.0.1" globals: "npm:^15.11.0" - motion: "npm:^12.0.0" react: "npm:^18.0.2" react-aria-components: "npm:^1.14.0" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" - react-stately: "npm:^3.35.0" storybook: "npm:^10.3.0-alpha.1" peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 @@ -15568,7 +15564,7 @@ __metadata: languageName: node linkType: hard -"@react-aria/button@npm:^3.14.4": +"@react-aria/button@npm:^3.13.3, @react-aria/button@npm:^3.14.4": version: 3.14.4 resolution: "@react-aria/button@npm:3.14.4" dependencies: @@ -44135,7 +44131,7 @@ __metadata: languageName: node linkType: hard -"react-aria@npm:^3.41.1, react-aria@npm:^3.45.0": +"react-aria@npm:^3.45.0": version: 3.46.0 resolution: "react-aria@npm:3.46.0" dependencies: @@ -44795,7 +44791,7 @@ __metadata: languageName: node linkType: hard -"react-stately@npm:^3.35.0, react-stately@npm:^3.43.0": +"react-stately@npm:^3.43.0": version: 3.43.0 resolution: "react-stately@npm:3.43.0" dependencies: