`<Transition>` wrapped by `<Suspense>` breaks entirely if interrupted before it completes
Vue version
3.2.47
Link to minimal reproduction
https://stackblitz.com/edit/github-z3ry59-hbu5x7 SFC Playground
Steps to reproduce
<template>
<transition name="page" mode="out-in" :duration="300">
<Suspense>
<component :is="Component" />
</Suspense>
</transition>
</template>
Click the button marked 'Trigger error'.
This will switch components within the Transition. On next tick, it will switch them back. (To reproduce, it's sufficient to switch them at any point before the transition has finished.)
Note that this follows the component order specified in https://vuejs.org/guide/built-ins/suspense.html#combining-with-other-components.
What is expected?
I expect no errors.
What is actually happening?
The following error is thrown:
Uncaught DOMException: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
In addition, the content of the Suspense slot is removed and the page remains broken.
System Info
No response
Any additional comments?
No response
This rings a bell, there could be another open issue about interrupting a transition here but I couldn't find it. It looks similar to https://github.com/vuejs/core/issues/6835 but clearly not the same.
After some investigations, I've found a workaround to relief the issue with the following patch.
--- packages/runtime-dom/src/nodeOps.ts
+++ packages/runtime-dom/src/nodeOps.ts
@@ -6,8 +6,18 @@ const doc = (typeof document !== 'undefined' ? document : null) as Document
const templateContainer = doc && /*#__PURE__*/ doc.createElement('template')
+function isSafeAnchor(parent: Element, anchor: Node | null | undefined) {
+ if (!anchor) return true;
+
+ for (let node: Node | null = anchor; node; node = node.parentNode) {
+ if (parent === node) return true;
+ }
+ return false;
+}
+
export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
insert: (child, parent, anchor) => {
+ if (!isSafeAnchor(parent, anchor)) anchor = null;
parent.insertBefore(child, anchor || null)
},
It also works on https://github.com/nuxt/nuxt/issues/13350
I also came up with logs that what's happend internally;
(using modified reproduction code that toggle
only toggles components once)
Good case:
- click the toggle button
insert
B to hiddenContainer created in mountSuspense (creation?)remove
A from real DOMinsert
B to real DOM, without anchor- wait for a while, and click the toggle button again.
insert
A to hiddenContainer created in mountSuspense (creation?)remove
B from real DOMinsert
A to real DOM, without anchor
Bad case:
- click the toggle button
insert
B to hiddenContainer created in mountSuspense (creation?)- click the toggle button again quickly, then component swap occurs again too rapidly.
insert
A to the same (!?) hiddenContainer created in mountSuspense (creation?)remove
B from the hiddenContainerremove
A from real DOMinsert
A to real DOM, with anchor A (!)
I hope it helps.
The issue happens when activeBranch
is truthy, and regardless of whether delayEnter
is true or not.
And, I think the anchor
re-selection in the if (activeBranch)
block needs to be robust (but I don't know how, yet)
Just to be confirmed, removing anchor = next(activeBranch)
also resolves the issue without the previous patch. (it fails some tests)
After deep investigation, I've finally found the solution.
--- packages/runtime-core/src/renderer.ts
+++ packages/runtime-core/src/renderer.ts
@@ -2035,6 +2035,7 @@ function baseCreateRenderer(
if (needTransition) {
if (moveType === MoveType.ENTER) {
transition!.beforeEnter(el!)
+ if (anchor && anchor.parent !== container) anchor = null;
hostInsert(el!, container, anchor)
queuePostRenderEffect(() => transition!.enter(el!), parentSuspense)
} else {
The beforeEnter
internally calls afterLeave
hooks, and it actually removes the anchor
from the container before use, thus hostInsert
claims it doesn't exist in the tree.
Perhaps, it should be re-calculate anchor
here, or you could call afterLeave
hooks manually to remove a transition node before anchor selection, but I think it's hard to do so here.
I've posted a PR (based on another idea), and I've noticed that the same pattern causes the same error, btw.
For example, following lines cause a HMR issue. (failed to reload a page sometimes) https://github.com/vuejs/core/blob/9f8e98af891f456cc8cc9019a31704e5534d1f08/packages/runtime-core/src/renderer.ts#L374-L375 After patching the same way in the PR like this, it has been resolved.
const anchorCands: RendererNode[] = []
for (let node = getNextHostNode(n1); node; node = hostNextSibling(node)) {
anchorCands.push(node)
}
unmount(n1, parentComponent, parentSuspense, true)
anchor = anchorCands.find(x => hostParentNode(x) === container) || null;
In current code, unmount
calls vnode's hooks internally, and they could remove
multiple nodes at once.
Thus, I think saving a next node as an anchor
before unmount is not a safe opearation anymore.
I'm trying to explain what's going on under the hood.
For this issue, there are 2 cases:
In this case, the
activeBranch
is still in thehiddenContainer
(I don't know if this is a valid state). Then,move
tries to move thependingBranch
where theactiveBranch
is, and it fails because theanchor
(where theactiveBranch
was) is not in thecontainer
.In the
!delayEnter
case, theanchor
is properly in thecontainer
, butmove
triggerstransition.beforeEnter
and hooks alter the DOM tree, thus theanchor
no longer exists in thecontainer
on the very nexthostInsert
. This could be another issue; it happens with my code at least, but I haven't created a minimal test code yet.
Additionally:
- This is clearly another issue.
unmount
removes multiple nodes at once, so capturing a singleanchor
before the unmount is not a safe operation, especially reloading by HMR causes it sometimes (I haven't found out the issue number yet).