From 4ba064a3a71852d7a86427104c513069f10fae6a Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 17:27:46 -0500 Subject: [PATCH 1/2] chore: [avatar, toast] upgrade base ui to 1.8.0 --- .../content/docs/components/avatar/index.mdx | 2 + .../content/docs/components/toast/index.mdx | 6 + .../content/docs/components/toast/props.ts | 14 +- .../avatar/__tests__/avatar.test.tsx | 124 ++++++++---------- .../avatar/__tests__/data-slots.test.tsx | 51 +------ .../react/components/avatar/avatar.module.css | 19 +++ packages/react/components/avatar/avatar.tsx | 34 +++-- .../components/toast/__tests__/toast.test.tsx | 20 +++ .../react/components/toast/toast-manager.ts | 25 +++- .../react/components/toast/toast-provider.tsx | 9 +- packages/react/package.json | 4 +- pnpm-lock.yaml | 32 +++-- 12 files changed, 187 insertions(+), 153 deletions(-) diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx index 03106bdca..76f7b4cb6 100644 --- a/apps/www/src/content/docs/components/avatar/index.mdx +++ b/apps/www/src/content/docs/components/avatar/index.mdx @@ -66,6 +66,8 @@ Avatar can display user images with graceful fallback to initials when images fa +The `` renders right away and loads in place, so an image in server-rendered HTML can show before hydration. While the image loads, or after it fails, the `` is hidden and the fallback shows. A load that takes 100ms or more fades in. A faster load, such as one from the cache, appears at once. The fallback stays hidden for the first 100ms of a load, so cached images do not flash the initials. + ### With generated colors `getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. Pass a stable ID, such as `user.id`, so the color does not change when someone is renamed. diff --git a/apps/www/src/content/docs/components/toast/index.mdx b/apps/www/src/content/docs/components/toast/index.mdx index 4c5da4826..37a9254f3 100644 --- a/apps/www/src/content/docs/components/toast/index.mdx +++ b/apps/www/src/content/docs/components/toast/index.mdx @@ -120,6 +120,12 @@ The returned object exposes the four manager methods: The `update(id, options)` method accepts a partial of the toast, with every field optional, used to patch an existing toast in place. +To build the update from the current toast, pass a function. It receives the current toast and returns the update options. + +```tsx +toastManager.update(id, prev => ({ title: `${prev.title} (retrying)` })); +``` + #### Promise options diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index bbf4a275a..561647b61 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -172,11 +172,17 @@ export interface CreateToastManagerReturn { /** * Update an existing toast in place. Typically used to swap a loading - * toast to a success or error state without dismissing it. + * toast to a success or error state without dismissing it. Pass a + * function to derive the update from the current toast. * - * @remarks `(id: string, options: ToastManagerUpdateOptions) => void` - */ - update: (id: string, options: ToastManagerUpdateOptions) => void; + * @remarks `(id: string, options: ToastManagerUpdateOptions | ((prevToast: ToastObject) => ToastManagerUpdateOptions)) => void` + */ + update: ( + id: string, + options: + | ToastManagerUpdateOptions + | ((prevToast: ToastObject) => ToastManagerUpdateOptions) + ) => void; /** * Tie a toast's lifecycle to a promise. Renders the `loading` toast diff --git a/packages/react/components/avatar/__tests__/avatar.test.tsx b/packages/react/components/avatar/__tests__/avatar.test.tsx index dcbbcee7c..ca796ae76 100644 --- a/packages/react/components/avatar/__tests__/avatar.test.tsx +++ b/packages/react/components/avatar/__tests__/avatar.test.tsx @@ -1,33 +1,34 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import { radiusClasses } from '../../../shared/radius'; import { Tooltip } from '../../tooltip'; import { Avatar, AvatarGroup } from '../avatar'; import styles from '../avatar.module.css'; import { AVATAR_COLORS, getAvatarColor } from '../utils'; -describe('Avatar', () => { - const ogImage = window.Image; - - beforeAll(() => { - window.Image = MockImage as unknown as typeof Image; - }); - - afterAll(() => { - window.Image = ogImage; - }); +const SRC = 'https://example.com/avatar.png'; +describe('Avatar', () => { describe('Basic Rendering', () => { - it('renders with image source', async () => { - const src = - 'https://images.unsplash.com/photo-1511485977113-f34c92461ad9'; - const rendered = render( - - ); + it('renders the image before it loads, hidden behind the fallback', () => { + const { container } = render(); + const img = container.querySelector('img'); + expect(img).toHaveAttribute('src', SRC); + expect(img).toHaveAttribute('data-loading'); + expect(img).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + it('shows the image once it loads', async () => { + const { container } = render(); + fireEvent.load(container.querySelector('img')!); + expect(await screen.findByRole('img', { name: 'JD' })).toBeVisible(); + expect(screen.queryByText('JD')).not.toBeInTheDocument(); + }); - const img = await rendered.findByRole('img'); - expect(img).toBeInTheDocument(); - expect(img).toHaveAttribute('src', src); + it('renders no image without src', () => { + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); }); it('applies custom className', () => { @@ -59,18 +60,49 @@ describe('Avatar', () => { it('calls onLoadingStatusChange with the image status', async () => { const onLoadingStatusChange = vi.fn(); - render( + const { container } = render( ); + expect(onLoadingStatusChange).toHaveBeenCalledWith('loading'); + fireEvent.load(container.querySelector('img')!); await waitFor(() => - expect(onLoadingStatusChange).toHaveBeenCalledWith('loaded') + expect(onLoadingStatusChange).toHaveBeenLastCalledWith('loaded') ); }); + it('marks the image as failed on error', async () => { + const { container } = render(); + const img = container.querySelector('img')!; + fireEvent.error(img); + await waitFor(() => expect(img).toHaveAttribute('data-error')); + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + it('skips the fade for a fast load', async () => { + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + const { container } = render(); + const img = container.querySelector('img')!; + now.mockReturnValue(20); + fireEvent.load(img); + await waitFor(() => expect(img).not.toHaveAttribute('data-loading')); + expect(img).not.toHaveClass(styles['image-fade-in']); + now.mockRestore(); + }); + + it('fades in after a slow load', async () => { + const now = vi.spyOn(performance, 'now').mockReturnValue(0); + const { container } = render(); + const img = container.querySelector('img')!; + now.mockReturnValue(500); + fireEvent.load(img); + await waitFor(() => expect(img).toHaveClass(styles['image-fade-in'])); + now.mockRestore(); + }); + it('waits for fallbackDelay before showing the fallback', async () => { render(); expect(screen.queryByText('JD')).not.toBeInTheDocument(); @@ -358,47 +390,3 @@ describe('Avatar', () => { }); }); }); - -class MockImage extends EventTarget { - _src: string = ''; - _complete: boolean = false; - onload: (() => void) | null = null; - - constructor() { - super(); - return this; - } - - get src() { - return this._src; - } - - set src(src: string) { - if (!src) { - return; - } - this._src = src; - // Simulate async image loading - setTimeout(() => { - this._complete = true; - // Call onload callback if set - if (this.onload) { - this.onload(); - } - // Also dispatch the event - this.dispatchEvent(new Event('load')); - }, 0); - } - - get complete() { - return this._complete; - } - - get naturalWidth() { - return this._complete ? 300 : 0; - } - - get naturalHeight() { - return this._complete ? 300 : 0; - } -} diff --git a/packages/react/components/avatar/__tests__/data-slots.test.tsx b/packages/react/components/avatar/__tests__/data-slots.test.tsx index 25c2368b9..5dbe52e79 100644 --- a/packages/react/components/avatar/__tests__/data-slots.test.tsx +++ b/packages/react/components/avatar/__tests__/data-slots.test.tsx @@ -1,64 +1,21 @@ -import { render } from '@testing-library/react'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; import { Avatar, AvatarGroup } from '../avatar'; -// jsdom has no real image decoder; simulate a successful async load so -// Base UI's AvatarPrimitive.Image moves past the loading status and renders. -class MockImage extends EventTarget { - _src: string = ''; - _complete: boolean = false; - onload: (() => void) | null = null; - - get src() { - return this._src; - } - - set src(src: string) { - if (!src) return; - this._src = src; - setTimeout(() => { - this._complete = true; - this.onload?.(); - this.dispatchEvent(new Event('load')); - }, 0); - } - - get complete() { - return this._complete; - } - - get naturalWidth() { - return this._complete ? 300 : 0; - } - - get naturalHeight() { - return this._complete ? 300 : 0; - } -} - describe('Avatar data-slot contract', () => { - const ogImage = window.Image; - - beforeAll(() => { - window.Image = MockImage as unknown as typeof Image; - }); - - afterAll(() => { - window.Image = ogImage; - }); - it('exposes the fallback slot before the image loads', () => { const { container } = render( ); - expectSlots(container, ['avatar', 'avatar-fallback']); + expectSlots(container, ['avatar', 'avatar-image', 'avatar-fallback']); }); it('exposes the image slot once loaded, replacing the fallback', async () => { const rendered = render( ); + fireEvent.load(rendered.container.querySelector('img')!); await rendered.findByRole('img'); expectSlots(rendered.container, ['avatar', 'avatar-image']); expect( diff --git a/packages/react/components/avatar/avatar.module.css b/packages/react/components/avatar/avatar.module.css index 34046ea4f..0d7ea8ec8 100644 --- a/packages/react/components/avatar/avatar.module.css +++ b/packages/react/components/avatar/avatar.module.css @@ -223,6 +223,25 @@ vertical-align: middle; } +/* The img is mounted before it loads. Keep it in layout so lazy loading still starts. */ +.image[data-loading], +.image[data-error] { + position: absolute; + inset: 0; + opacity: 0; +} + +/* Hide the fallback briefly so cached images don't flash the initials first. */ +.image[data-loading] ~ .fallback { + animation: avatar-fallback-delay 0s 100ms backwards; +} + +@keyframes avatar-fallback-delay { + from { + visibility: hidden; + } +} + @keyframes avatar-image-in { from { opacity: 0; diff --git a/packages/react/components/avatar/avatar.tsx b/packages/react/components/avatar/avatar.tsx index 7b4a576bc..d87b1f6a9 100644 --- a/packages/react/components/avatar/avatar.tsx +++ b/packages/react/components/avatar/avatar.tsx @@ -17,6 +17,10 @@ import type { AvatarColor } from './utils'; // Matches Base UI's AvatarRoot ImageLoadingStatus union. type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'; +// Loads faster than this come from the cache and appear without a fade. +// Matches the fallback hide delay in avatar.module.css. +const FADE_IN_THRESHOLD_MS = 100; + const avatar = cva(styles.avatar, { variants: { // Each size names its own base step. No default: the theme radius applies. @@ -173,18 +177,19 @@ const AvatarRoot = ({ color, ...props }: AvatarProps) => { - const sawLoadingRef = useRef(false); + const loadStartRef = useRef(null); const [fadeIn, setFadeIn] = useState(false); const handleLoadingStatusChange = (status: ImageLoadingStatus) => { onLoadingStatusChange?.(status); if (status === 'loading') { - sawLoadingRef.current = true; + loadStartRef.current = performance.now(); setFadeIn(false); } else if (status === 'loaded') { - // A cached image reports 'loaded' with no 'loading' phase (Base UI never - // emits it), so it stays instant; only network loads fade in. - setFadeIn(sawLoadingRef.current); - sawLoadingRef.current = false; + const start = loadStartRef.current; + setFadeIn( + start !== null && performance.now() - start >= FADE_IN_THRESHOLD_MS + ); + loadStartRef.current = null; } }; @@ -198,13 +203,16 @@ const AvatarRoot = ({ data-slot='avatar' {...props} > - + {src && ( + + )} { expect(screen.getByText('Updated title')).toBeInTheDocument(); }); }); + + it('accepts a function that receives the current toast', async () => { + let id: string; + act(() => { + id = toastManager.add({ title: 'Count 1' }); + }); + expect(await screen.findByText('Count 1')).toBeInTheDocument(); + + act(() => { + toastManager.update(id!, prev => ({ + title: `${prev.title} + 1`, + leadingIcon: + })); + }); + + await waitFor(() => { + expect(screen.getByText('Count 1 + 1')).toBeInTheDocument(); + expect(screen.getByTestId('fn-icon')).toBeInTheDocument(); + }); + }); }); describe('toastManager.promise()', () => { diff --git a/packages/react/components/toast/toast-manager.ts b/packages/react/components/toast/toast-manager.ts index 55918c37a..59388321b 100644 --- a/packages/react/components/toast/toast-manager.ts +++ b/packages/react/components/toast/toast-manager.ts @@ -1,6 +1,10 @@ 'use client'; -import { Toast as ToastPrimitive } from '@base-ui/react'; +import { + type ToastManagerUpdateOptions, + type ToastObject, + Toast as ToastPrimitive +} from '@base-ui/react'; import { type ReactNode, useMemo } from 'react'; export interface ToastData { @@ -27,9 +31,13 @@ export type ToastAddOptions = WithModifiedOptions< >; export type ToastUpdateOptions = WithModifiedOptions< - Parameters[1] + ToastManagerUpdateOptions >; +export type ToastUpdater = + | ToastUpdateOptions + | ((prevToast: ToastObject) => ToastUpdateOptions); + export interface ToastPromiseOptions { loading: string | ToastUpdateOptions; success: @@ -48,6 +56,13 @@ function lift(options: O) { return { ...rest, data: { leadingIcon } }; } +function liftUpdater(updater: ToastUpdater) { + if (typeof updater === 'function') { + return (prevToast: ToastObject) => lift(updater(prevToast)); + } + return lift(updater); +} + function liftDescriptor(option: string | ToastUpdateOptions) { return typeof option === 'string' ? option : lift(option); } @@ -84,7 +99,7 @@ function liftPromiseOptions({ export interface ToastManager extends Omit { add: (options: ToastAddOptions) => string; - update: (id: string, options: ToastUpdateOptions) => void; + update: (id: string, options: ToastUpdater) => void; promise: ( promise: Promise, options: ToastPromiseOptions @@ -96,7 +111,7 @@ export function createToastManager(): ToastManager { return { ...base, add: options => base.add(lift(options)), - update: (id, options) => base.update(id, lift(options)), + update: (id, options) => base.update(id, liftUpdater(options)), promise: (promise, options) => base.promise(promise, liftPromiseOptions(options)) }; @@ -126,7 +141,7 @@ export function useToastManager(): UseToastManagerReturn { () => ({ ...base, add: options => base.add(lift(options)), - update: (id, options) => base.update(id, lift(options)), + update: (id, options) => base.update(id, liftUpdater(options)), promise: (promise, options) => base.promise(promise, liftPromiseOptions(options)) }), diff --git a/packages/react/components/toast/toast-provider.tsx b/packages/react/components/toast/toast-provider.tsx index 511641b77..013864a7b 100644 --- a/packages/react/components/toast/toast-provider.tsx +++ b/packages/react/components/toast/toast-provider.tsx @@ -48,7 +48,14 @@ export function ToastProvider({ }: ToastProviderProps) { const theme = useThemeInjection(); return ( - + {children} =6.9.0'} + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.7.0': - resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -452,8 +456,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.3.2': - resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -6517,12 +6521,14 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/runtime@7.29.10': {} + '@babel/runtime@7.29.2': {} - '@base-ui/react@1.7.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/react@1.8.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@babel/runtime': 7.29.10 + '@base-ui/utils': 0.4.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@floating-ui/utils': 0.2.12 react: 19.2.1 @@ -6533,9 +6539,9 @@ snapshots: '@types/react': 19.1.9 date-fns: 4.1.0 - '@base-ui/utils@0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/utils@0.4.0(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.10 '@floating-ui/utils': 0.2.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) From d715734c894799489d21c53ba6d758be5ac9139b Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 20:11:15 -0500 Subject: [PATCH 2/2] fix: [avatar, toast] position avatar image before hydration, let update reset icon --- .../content/docs/components/toast/props.ts | 2 ++ .../react/components/avatar/avatar.module.css | 6 ++--- .../components/toast/__tests__/toast.test.tsx | 23 +++++++++++++++++++ .../react/components/toast/toast-manager.ts | 3 ++- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/www/src/content/docs/components/toast/props.ts b/apps/www/src/content/docs/components/toast/props.ts index 561647b61..9ed4e6bc1 100644 --- a/apps/www/src/content/docs/components/toast/props.ts +++ b/apps/www/src/content/docs/components/toast/props.ts @@ -147,6 +147,8 @@ export interface ToastObject { type?: 'success' | 'error' | 'info' | 'warning' | 'loading'; timeout?: number; priority?: 'low' | 'high'; + /** Extra toast data. `leadingIcon` holds the custom icon, if one was set. */ + data?: { leadingIcon?: React.ReactNode }; } /** diff --git a/packages/react/components/avatar/avatar.module.css b/packages/react/components/avatar/avatar.module.css index 0d7ea8ec8..ba4252682 100644 --- a/packages/react/components/avatar/avatar.module.css +++ b/packages/react/components/avatar/avatar.module.css @@ -221,13 +221,13 @@ width: 100%; height: 100%; vertical-align: middle; + /* The img is mounted before it loads, so it sits on top of the fallback instead of next to it. */ + position: absolute; + inset: 0; } -/* The img is mounted before it loads. Keep it in layout so lazy loading still starts. */ .image[data-loading], .image[data-error] { - position: absolute; - inset: 0; opacity: 0; } diff --git a/packages/react/components/toast/__tests__/toast.test.tsx b/packages/react/components/toast/__tests__/toast.test.tsx index 5c2152262..2e860aab5 100644 --- a/packages/react/components/toast/__tests__/toast.test.tsx +++ b/packages/react/components/toast/__tests__/toast.test.tsx @@ -289,6 +289,29 @@ describe('Toast', () => { expect(screen.getByTestId('updated-icon')).toBeInTheDocument(); }); }); + + it('restores the default icon when update() sets leadingIcon to undefined', async () => { + let id: string; + act(() => { + id = toastManager.add({ + title: 'Custom', + leadingIcon: + }); + }); + expect(await screen.findByTestId('custom-icon')).toBeInTheDocument(); + + act(() => { + toastManager.update(id!, () => ({ leadingIcon: undefined })); + }); + + await waitFor(() => { + expect(screen.queryByTestId('custom-icon')).not.toBeInTheDocument(); + }); + const toast = screen.getByText('Custom').closest('[data-slot="toast"]'); + expect( + toast?.querySelector('[data-slot="toast-leading-icon"]') + ).toBeInTheDocument(); + }); }); describe('Multiple toasts', () => { diff --git a/packages/react/components/toast/toast-manager.ts b/packages/react/components/toast/toast-manager.ts index 59388321b..bae2fe155 100644 --- a/packages/react/components/toast/toast-manager.ts +++ b/packages/react/components/toast/toast-manager.ts @@ -52,7 +52,8 @@ export interface ToastPromiseOptions { function lift(options: O) { const { leadingIcon, ...rest } = options; - if (leadingIcon === undefined) return rest; + // An explicit `leadingIcon: undefined` resets the toast to its type's default icon. + if (!('leadingIcon' in options)) return rest; return { ...rest, data: { leadingIcon } }; }