Skip to content

Commit 6d05a1a

Browse files
committed
[toast] Fix re-adding a closing toast
1 parent 9914a31 commit 6d05a1a

3 files changed

Lines changed: 177 additions & 13 deletions

File tree

packages/react/src/toast/root/ToastRoot.test.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,150 @@ describe('<Toast.Root />', () => {
228228
});
229229
});
230230

231+
it.skipIf(isJSDOM)(
232+
'clears the starting state and restores the height when re-adding an ending toast',
233+
async () => {
234+
const animationsDisabled = globalThis.BASE_UI_ANIMATIONS_DISABLED;
235+
globalThis.BASE_UI_ANIMATIONS_DISABLED = false;
236+
237+
function App() {
238+
const { add, close, toasts } = Toast.useToastManager();
239+
240+
return (
241+
<React.Fragment>
242+
<style>
243+
{`
244+
[data-testid="toast-root"] {
245+
opacity: 1;
246+
transition: opacity 10s;
247+
}
248+
249+
[data-testid="toast-root"][data-ending-style] {
250+
opacity: 0;
251+
}
252+
`}
253+
</style>
254+
<button type="button" onClick={() => add({ id: 'save', title: 'Saved', timeout: 0 })}>
255+
add
256+
</button>
257+
<button type="button" onClick={() => close('save')}>
258+
close
259+
</button>
260+
<Toast.Viewport>
261+
{toasts.map((toastItem) => (
262+
<Toast.Root key={toastItem.id} toast={toastItem} data-testid="toast-root">
263+
<Toast.Title />
264+
</Toast.Root>
265+
))}
266+
</Toast.Viewport>
267+
</React.Fragment>
268+
);
269+
}
270+
271+
try {
272+
const { user } = await render(
273+
<Toast.Provider>
274+
<App />
275+
</Toast.Provider>,
276+
);
277+
278+
await user.click(screen.getByRole('button', { name: 'add' }));
279+
const toastRoot = screen.getByTestId('toast-root');
280+
expect(toastRoot).not.toHaveAttribute('data-starting-style');
281+
const initialHeight = toastRoot.style.getPropertyValue('--toast-height');
282+
expect(initialHeight).not.toBe('');
283+
284+
await user.click(screen.getByRole('button', { name: 'close' }));
285+
expect(toastRoot).toHaveAttribute('data-ending-style');
286+
expect(toastRoot.style.getPropertyValue('--toast-height')).toBe('');
287+
288+
await user.click(screen.getByRole('button', { name: 'add' }));
289+
expect(screen.getByTestId('toast-root')).toBe(toastRoot);
290+
291+
await waitFor(() => {
292+
expect(toastRoot).not.toHaveAttribute('data-starting-style');
293+
});
294+
await waitFor(() => {
295+
expect(toastRoot.style.getPropertyValue('--toast-height')).toBe(initialHeight);
296+
});
297+
} finally {
298+
globalThis.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled;
299+
}
300+
},
301+
);
302+
303+
it.skipIf(isJSDOM)('registers an active toast after its root remounts', async () => {
304+
function App() {
305+
const { add, toasts } = Toast.useToastManager();
306+
const [showToasts, setShowToasts] = React.useState(true);
307+
const [longTitle, setLongTitle] = React.useState(false);
308+
309+
return (
310+
<React.Fragment>
311+
<button type="button" onClick={() => add({ id: 'save', title: 'Saved', timeout: 0 })}>
312+
add
313+
</button>
314+
<button type="button" onClick={() => setShowToasts(false)}>
315+
hide
316+
</button>
317+
<button
318+
type="button"
319+
onClick={() => {
320+
setLongTitle(true);
321+
setShowToasts(true);
322+
}}
323+
>
324+
show
325+
</button>
326+
<Toast.Viewport>
327+
{showToasts
328+
? toasts.map((toastItem) => (
329+
<Toast.Root
330+
key={toastItem.id}
331+
toast={toastItem}
332+
data-testid="toast-root"
333+
style={{ width: 30 }}
334+
>
335+
<Toast.Title>
336+
{longTitle ? 'This title is much longer than before' : undefined}
337+
</Toast.Title>
338+
</Toast.Root>
339+
))
340+
: null}
341+
</Toast.Viewport>
342+
</React.Fragment>
343+
);
344+
}
345+
346+
const { user } = await render(
347+
<Toast.Provider>
348+
<App />
349+
</Toast.Provider>,
350+
);
351+
352+
await user.click(screen.getByRole('button', { name: 'add' }));
353+
const initialRoot = screen.getByTestId('toast-root');
354+
expect(initialRoot).not.toHaveAttribute('data-starting-style');
355+
const initialHeight = parseInt(initialRoot.style.getPropertyValue('--toast-height'), 10);
356+
357+
await user.click(screen.getByRole('button', { name: 'hide' }));
358+
expect(screen.queryByTestId('toast-root')).toBe(null);
359+
360+
await user.click(screen.getByRole('button', { name: 'show' }));
361+
const remountedRoot = screen.getByTestId('toast-root');
362+
expect(remountedRoot).not.toBe(initialRoot);
363+
364+
await waitFor(() => {
365+
const remountedHeight = parseInt(remountedRoot.style.getPropertyValue('--toast-height'), 10);
366+
expect(remountedHeight).toBeGreaterThan(initialHeight);
367+
});
368+
369+
await user.keyboard('{F6}');
370+
await user.keyboard('{Tab}');
371+
372+
expect(remountedRoot).toHaveFocus();
373+
});
374+
231375
// requires :focus-visible check
232376
it.skipIf(isJSDOM)('closes when pressing escape', async () => {
233377
const { user } = await render(

packages/react/src/toast/root/ToastRoot.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
7979
);
8080

8181
const rootRef = React.useRef<HTMLDivElement | null>(null);
82+
const hasInitializedRef = React.useRef(false);
8283
const dragStartPosRef = React.useRef({ x: 0, y: 0 });
8384
const initialTransformRef = React.useRef({ x: 0, y: 0, scale: 1 });
8485
const intendedSwipeDirectionRef = React.useRef<'up' | 'down' | 'left' | 'right' | undefined>(
@@ -116,18 +117,11 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
116117
return;
117118
}
118119

119-
const previousHeight = element.style.height;
120-
element.style.height = 'auto';
121-
122-
const height = element.offsetHeight;
123-
124-
element.style.height = previousHeight;
120+
const height = measureHeight(element);
125121

126122
function update() {
127123
store.updateToastInternal(toast.id, {
128-
ref: rootRef,
129124
height,
130-
transitionStatus: undefined,
131125
});
132126
}
133127

@@ -138,7 +132,24 @@ export const ToastRoot = React.forwardRef(function ToastRoot(
138132
}
139133
});
140134

141-
useIsoLayoutEffect(recalculateHeight, [recalculateHeight]);
135+
// Initialize newly mounted roots, and reinitialize a retained root when its toast is revived.
136+
useIsoLayoutEffect(() => {
137+
if (hasInitializedRef.current && toast.transitionStatus !== 'starting') {
138+
return;
139+
}
140+
141+
const element = rootRef.current;
142+
if (!element) {
143+
return;
144+
}
145+
146+
hasInitializedRef.current = true;
147+
store.updateToastInternal(toast.id, {
148+
ref: rootRef,
149+
height: measureHeight(element),
150+
transitionStatus: undefined,
151+
});
152+
}, [store, toast.id, toast.transitionStatus]);
142153

143154
function setResolvedDragOffset(nextDragOffset: { x: number; y: number }) {
144155
dragOffsetRef.current = nextDragOffset;
@@ -541,3 +552,13 @@ export namespace ToastRoot {
541552
export type State = ToastRootState;
542553
export type Props = ToastRootProps;
543554
}
555+
556+
function measureHeight(element: HTMLElement) {
557+
const previousHeight = element.style.height;
558+
element.style.height = 'auto';
559+
560+
const height = element.offsetHeight;
561+
562+
element.style.height = previousHeight;
563+
return height;
564+
}

packages/react/src/toast/store.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,9 @@ describe('ToastStore', () => {
6969
store.closeToast('a');
7070
expect(selectors.toast(store.state, 'a')?.transitionStatus).toBe('ending');
7171

72-
// Mirrors the write `recalculateHeight` makes when a content observer fires:
73-
// it always includes `transitionStatus: undefined`. The ending toast must stay
74-
// ending so `useOpenChangeComplete` still removes it.
75-
store.updateToastInternal('a', { height: 80, transitionStatus: undefined });
72+
// Mirrors the height update a content observer makes. The ending toast must
73+
// stay ending so `useOpenChangeComplete` still removes it.
74+
store.updateToastInternal('a', { height: 80 });
7675

7776
const toast = selectors.toast(store.state, 'a');
7877
expect(toast?.transitionStatus).toBe('ending');

0 commit comments

Comments
 (0)