Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 84 additions & 24 deletions packages/components/src/navigation/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,43 @@ export type StackProps = {
'data-testid'?: string;
};

/**
* Identifies a stack view by its React `key` rather than by element identity.
*
* The parent that renders the stack may re-render frequently (e.g. an
* `IrisGrid` re-renders on every table tick) and hand us brand-new element
* objects that represent the *same* logical view. Tracking the animating views
* by `key` (rather than by element reference) means those re-renders neither
* retrigger the slide animation nor churn state every frame.
*
* Children are always run through `React.Children.toArray`, which assigns a
* stable key to every element, so element views always have a key.
*/
function getViewKey(node: React.ReactNode): React.Key | null {
return React.isValidElement(node) ? node.key : null;
}
Comment thread
vbabich marked this conversation as resolved.

/**
* Finds the child that currently represents the view identified by `key`.
*
* Returning the *current* element (instead of a stored copy) keeps the rendered
* view's content live: when a parent re-renders with a new element instance for
* the same logical view, the view's latest props are forwarded to the mounted
* component. This matters for prop-driven panels such as the "Organize Columns"
* (`VisibilityOrderingBuilder`) sidebar, whose list contents and Undo/Redo state
* arrive entirely via props from the grid - freezing those props would stop the
* panel from reflecting moves, hides, groupings, and undo/redo.
*/
function findViewByKey(
views: readonly React.ReactNode[],
key: React.Key | null
): React.ReactNode {
if (key == null) {
return null;
}
return views.find(view => getViewKey(view) === key) ?? null;
}

/**
* Pass a full navigation stack of children, and then automatically does a sliding animation when the stack changes.
* Adding items to the stack will do a "push" animation, and removing items from the stack will do a "pop" animation.
Expand All @@ -21,11 +58,21 @@ export function Stack({
[children]
);
const prevChildrenArray = usePrevious(childrenArray);
const [mainView, setMainView] = useState<React.ReactNode>(
childrenArray[childrenArray.length - 1]

// The animating views are tracked by `key`, not element identity, so that
// frequent parent re-renders (which produce new-but-equivalent elements)
// neither retrigger animations nor freeze content. The element actually
// rendered is always looked up from the latest `childrenArray` via
// `findViewByKey`, keeping each view's props live.
const [mainViewKey, setMainViewKey] = useState<React.Key | null>(() =>
Comment thread
vbabich marked this conversation as resolved.
getViewKey(childrenArray[childrenArray.length - 1])
);

const [pushingView, setPushingView] = useState<React.ReactNode>(null);
const [pushingViewKey, setPushingViewKey] = useState<React.Key | null>(null);

// The popping view has already been removed from `children`, so we keep the
// element instance itself - it is only animating out and its content does not
// need to stay live.
const [poppingView, setPoppingView] = useState<React.ReactNode>(null);

/**
Expand All @@ -36,6 +83,10 @@ export function Stack({
*
* When the `pushingView` or `poppingView` is set, that will kick off their animation.
* Completion of the animation is handled in `pushComplete` or `popComplete`, and then the stack is in an idle state again.
*
* Views are compared by `key` (see `getViewKey`) so that a parent that
* re-renders with new element instances for the same logical view does not
* retrigger animations or churn state on every render.
*/
useEffect(
function initAnimation() {
Expand All @@ -45,42 +96,51 @@ export function Stack({
) {
return;
}
const topChild = childrenArray[childrenArray.length - 1];
if (
const topChildKey = getViewKey(childrenArray[childrenArray.length - 1]);

if (pushingViewKey !== null || poppingView !== null) {
// We're mid-animation. Keep the animating view pointed at the current
// top view, but don't start a new animation.
if (pushingViewKey !== null) {
if (topChildKey !== pushingViewKey) {
// A different view was pushed mid-animation - animate to it instead
setPushingViewKey(topChildKey);
}
} else if (topChildKey !== mainViewKey) {
setMainViewKey(topChildKey);
}
} else if (
childrenArray.length === prevChildrenArray.length ||
prevChildrenArray.length === 0 ||
pushingView !== null ||
poppingView !== null
prevChildrenArray.length === 0
) {
// 1) Stack is the same size, we've just mounted, or we're already in an animation - just update the view
if (pushingView !== null && topChild !== pushingView) {
// Stack was updated mid animation
setPushingView(topChild);
} else if (topChild !== poppingView && topChild !== mainView) {
// Replace the current view
setMainView(topChild);
// Stack is the same size or we've just mounted - just update the view
if (topChildKey !== mainViewKey) {
setMainViewKey(topChildKey);
}
} else if (childrenArray.length > prevChildrenArray.length) {
// 2) Stack has grown - start the push animation
setPushingView(topChild);
} else if (childrenArray.length < prevChildrenArray.length) {
// 3) Stack has shrunk - start the pop animation
setMainView(topChild);
// Stack has grown - start the push animation
setPushingViewKey(topChildKey);
} else {
// Stack has shrunk - start the pop animation
setMainViewKey(topChildKey);
setPoppingView(prevChildrenArray[prevChildrenArray.length - 1]);
}
},
[childrenArray, prevChildrenArray, pushingView, poppingView, mainView]
[childrenArray, prevChildrenArray, pushingViewKey, poppingView, mainViewKey]
);

const pushComplete = useCallback(() => {
setMainView(pushingView);
setPushingView(null);
}, [pushingView]);
setMainViewKey(pushingViewKey);
setPushingViewKey(null);
}, [pushingViewKey]);

const popComplete = useCallback(() => {
setPoppingView(null);
}, []);

const mainView = findViewByKey(childrenArray, mainViewKey);
const pushingView = findViewByKey(childrenArray, pushingViewKey);

return (
<div className="navigation-stack">
<div className="main-view" data-testid={dataTestId}>
Expand Down
18 changes: 13 additions & 5 deletions packages/components/src/transitions/SlideTransition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,19 @@ function SlideTransition({
}: SlideTransitionProps): JSX.Element {
const nodeRef = useRef<HTMLElement | null>(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.
// Re-attaching the ref whenever `in` toggles re-reads `firstElementChild`
// exactly when the child appears or disappears, so `nodeRef` points at the
// current child before CSSTransition triggers the enter/exit animation.
// (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;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[props.in]
);
Comment thread
vbabich marked this conversation as resolved.
Comment thread
vbabich marked this conversation as resolved.

return (
<CSSTransition
Expand Down
Loading