diff --git a/packages/components/src/transitions/SlideTransition.test.tsx b/packages/components/src/transitions/SlideTransition.test.tsx
new file mode 100644
index 0000000000..573675097b
--- /dev/null
+++ b/packages/components/src/transitions/SlideTransition.test.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { act, render } from '@testing-library/react';
+import SlideTransition from './SlideTransition';
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+
+afterEach(() => {
+ act(() => {
+ jest.runOnlyPendingTimers();
+ });
+ jest.useRealTimers();
+});
+
+it('renders its children', () => {
+ const { getByTestId } = render(
+
+ content
+
+ );
+
+ expect(getByTestId('child')).toBeTruthy();
+});
+
+it('points the transition ref at the first child element, applying slide classes to it', () => {
+ // The child only exists while `in` is true, mimicking how `Stack` renders the
+ // popping/pushing view conditionally. Re-reading `firstElementChild` when
+ // `in` toggles is what lets CSSTransition animate the child once it appears.
+ function Wrapper({ on }: { on: boolean }): JSX.Element {
+ return (
+
+ {/* eslint-disable-next-line react/jsx-no-useless-fragment */}
+ <>{on && content
}>
+
+ );
+ }
+
+ const { rerender, queryByTestId, getByTestId } = render(
+
+ );
+ // Child is not rendered while `in` is false.
+ expect(queryByTestId('child')).toBeNull();
+
+ act(() => {
+ rerender(
);
+ });
+
+ // The newly-appeared child receives the slide transition classes, proving the
+ // ref was re-attached to `firstElementChild` when `in` toggled.
+ const child = getByTestId('child');
+ expect(child.className).toContain('slide-left');
+});
+
+it('applies the slide-right class when direction is right', () => {
+ // CSSTransition only adds enter classes when `in` transitions to true, so we
+ // toggle `in` to trigger the animation rather than mounting with `in` true.
+ function Wrapper({ on }: { on: boolean }): JSX.Element {
+ return (
+
+ {/* eslint-disable-next-line react/jsx-no-useless-fragment */}
+ <>{on && content
}>
+
+ );
+ }
+
+ const { rerender, getByTestId } = render(
);
+
+ act(() => {
+ rerender(
);
+ });
+
+ expect(getByTestId('child').className).toContain('slide-right');
+});
+
+it('unmounts cleanly, detaching the ref', () => {
+ const { unmount } = render(
+
+ content
+
+ );
+
+ // Unmounting invokes the ref callback with `null`.
+ expect(() => unmount()).not.toThrow();
+});
diff --git a/packages/components/src/transitions/SlideTransition.tsx b/packages/components/src/transitions/SlideTransition.tsx
index 9d2e526893..4869ed1c01 100644
--- a/packages/components/src/transitions/SlideTransition.tsx
+++ b/packages/components/src/transitions/SlideTransition.tsx
@@ -45,11 +45,19 @@ function SlideTransition({
}: SlideTransitionProps): JSX.Element {
const nodeRef = useRef
(null);
- // Mimics findDOMNode for CSSTransition
- // The ref should be set before CSSTransition does anything with it
- const setRef = useCallback((node: HTMLElement | null) => {
- nodeRef.current = node?.firstElementChild as HTMLElement;
- }, []);
+ // Mimics findDOMNode for CSSTransition.
+ // Keying on `in` rather than `children` avoids re-creating the ref callback
+ // on every render, which would needlessly detach/re-attach the ref.
+ const setRef = useCallback(
+ (node: HTMLElement | null) => {
+ nodeRef.current = (node?.firstElementChild as HTMLElement | null) ?? null;
+ },
+ // `inProp` is intentionally a dependency: toggling `in` must re-create the
+ // callback so the ref re-attaches and re-reads `firstElementChild` when the
+ // child appears/disappears.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [props.in]
+ );
return (
{
>
-
-
- {openOptionsStack.map((option, i) => (
+ {[
- {option}
-
- ))}
+