Skip to content

fix(runtime-dom): inconsistent behaviour on nested transition groups #8803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
32 changes: 25 additions & 7 deletions packages/runtime-dom/src/components/TransitionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ import {
} from '@vue/runtime-core'
import { extend } from '@vue/shared'

const positionMap = new WeakMap<VNode, DOMRect>()
const newPositionMap = new WeakMap<VNode, DOMRect>()
interface Position {
left: number
top: number
}

const positionMap = new WeakMap<VNode, Position>()
const newPositionMap = new WeakMap<VNode, Position>()
const moveCbKey = Symbol('_moveCb')
const enterCbKey = Symbol('_enterCb')

Expand Down Expand Up @@ -145,10 +150,7 @@ const TransitionGroupImpl: ComponentOptions = /*@__PURE__*/ decorate({
instance,
),
)
positionMap.set(
child,
(child.el as Element).getBoundingClientRect(),
)
positionMap.set(child, getRelativePosition(child.el as Element))
}
}
}
Expand Down Expand Up @@ -188,8 +190,24 @@ function callPendingCbs(c: VNode) {
}
}

function getRelativePosition(el: Element): Position {
const elRect = el.getBoundingClientRect()
if (!el.parentElement) {
return {
left: elRect.left,
top: elRect.top,
}
}

const parentRect = el.parentElement.getBoundingClientRect()
return {
left: elRect.left - parentRect.left,
top: elRect.top - parentRect.top,
}
}
Comment on lines +193 to +207
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

parentElement may be null – relative delta becomes wrong

getRelativePosition falls back to absolute viewport coords when el.parentElement is null (e.g. the parent is a DocumentFragment, a comment, or the node is temporarily detached).
If that happens between the “old” and “new” snapshots, dx/dy will be computed against mixed coordinate spaces → items jump instead of animating.

Proposed defensive tweak:

function getRelativePosition(el: Element): Position {
-  const elRect = el.getBoundingClientRect()
-  if (!el.parentElement) {
-    return {
-      left: elRect.left,
-      top: elRect.top,
-    }
-  }
-
-  const parentRect = el.parentElement.getBoundingClientRect()
+  const elRect = el.getBoundingClientRect()
+  const parent = el.parentElement as Element | null
+  if (!parent) {
+    // No element parent (e.g. DocumentFragment) — treat the container itself
+    // as the reference so both snapshots stay in the same coordinate space.
+    return { left: elRect.left, top: elRect.top }
+  }
+
+  const parentRect = parent.getBoundingClientRect()
   return {
     left: elRect.left - parentRect.left,
     top: elRect.top - parentRect.top,
   }
 }

This keeps both snapshots in the same space and avoids incorrect deltas.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/runtime-dom/src/components/TransitionGroup.ts around lines 193 to
207, the getRelativePosition function returns absolute viewport coordinates when
el.parentElement is null, causing inconsistent coordinate spaces and incorrect
delta calculations. To fix this, modify the function to always compute positions
relative to a consistent ancestor or coordinate space, such as the document or a
stable container, ensuring both old and new snapshots use the same reference
frame and preventing animation jumps.


function recordPosition(c: VNode) {
newPositionMap.set(c, (c.el as Element).getBoundingClientRect())
newPositionMap.set(c, getRelativePosition(c.el as Element))
}

function applyTranslation(c: VNode): VNode | undefined {
Expand Down