Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
117 changes: 117 additions & 0 deletions dev/html/public/animate-layout/shared-element-stale-spa.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<html>
<!--
This test simulates stale shared layout nodes across SPA navigations.
It verifies resumeFrom is not set to a disconnected instance.
-->
<head>
<style>
body {
padding: 0;
margin: 0;
}

#container {
width: 400px;
height: 400px;
background-color: #f0f0f0;
position: relative;
}

.card {
position: absolute;
background-color: #00cc88;
}

.card.small {
top: 0;
left: 0;
width: 100px;
height: 100px;
}

.card.big {
top: 200px;
left: 200px;
width: 200px;
height: 200px;
background-color: #09f;
}

[data-layout-correct="false"] {
background: #dd1144 !important;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="container"></div>

<script type="module" src="/src/imports/projection.js"></script>
<script type="module" src="/src/imports/script-animate.js"></script>
<script type="module" src="/src/imports/script-assert.js"></script>
<script type="module">
const { showError } = window
const { createNode } = window.Animate
const container = document.getElementById("container")

// Root projection node for shared layout tracking.
const root = createNode(container, undefined, { layout: true })

// Create an initial shared element and register it.
const staleElement = document.createElement("div")
staleElement.className = "card small"
staleElement.setAttribute("data-layout-id", "hero")
container.appendChild(staleElement)
createNode(staleElement, root, { layoutId: "hero" })

// Simulate navigation away: remove element without unmount.
staleElement.remove()

// Create a new shared element that exits and relegate it.
const exitingElement = document.createElement("div")
exitingElement.className = "card small"
exitingElement.setAttribute("data-layout-id", "hero")
container.appendChild(exitingElement)
const exitingNode = createNode(exitingElement, root, {
layoutId: "hero",
})
exitingNode.isPresent = false
exitingNode.relegate()

// Create the next shared element and check resumeFrom.
const nextElement = document.createElement("div")
nextElement.className = "card big"
nextElement.setAttribute("data-layout-id", "hero")
container.appendChild(nextElement)
const nextNode = createNode(nextElement, root, {
layoutId: "hero",
})

const resumeFrom = nextNode.resumeFrom
const resumeFromConnected = Boolean(
resumeFrom?.instance && resumeFrom.instance.isConnected
)
const resumeFromHasSnapshot = Boolean(resumeFrom?.snapshot)
const resumeFromIsPresent = resumeFrom?.isPresent !== false
const resumeFromInvalid =
resumeFrom &&
resumeFromIsPresent &&
!resumeFromConnected &&
!resumeFromHasSnapshot
window.__debugResumeFrom = {
exists: Boolean(resumeFrom),
connected: resumeFromConnected,
invalid: Boolean(resumeFromInvalid),
hasSnapshot: resumeFromHasSnapshot,
isPresent: resumeFromIsPresent,
}

if (resumeFromInvalid) {
nextElement.dataset.layoutCorrect = "false"
console.error(
"resumeFrom should not point to a disconnected instance"
)
}
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","modal-open-close-interrupt.html","modal-open-close-open-interrupt.html","modal-open-close-open.html","modal-open-close.html","modal-open-opacity.html","modal-open.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-multiple-elements.html"]
["app-store-a-b-a.html","basic-position-change.html","interrupt-animation.html","modal-open-close-interrupt.html","modal-open-close-open-interrupt.html","modal-open-close-open.html","modal-open-close.html","modal-open-opacity.html","modal-open.html","repeat-animation.html","scale-correction.html","scope-with-data-layout.html","shared-element-a-ab-a.html","shared-element-a-b-a-replace.html","shared-element-a-b-a-reuse.html","shared-element-basic.html","shared-element-configured.html","shared-element-crossfade.html","shared-element-nested-children-bottom.html","shared-element-nested-children.html","shared-element-no-crossfade.html","shared-element-stale-spa.html","shared-multiple-elements.html"]
15 changes: 14 additions & 1 deletion packages/motion-dom/src/projection/shared/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ export class NodeStack {
members: IProjectionNode[] = []

add(node: IProjectionNode) {
// Remove stale members with disconnected DOM instances (e.g., from SPA navigations)
this.members = this.members.filter(
(m) => !m.instance || m.instance.isConnected
)
if (this.prevLead && !this.members.includes(this.prevLead)) {
this.prevLead = undefined
}
if (this.lead && !this.members.includes(this.lead)) {
this.lead = undefined
}

addUniqueItem(this.members, node)
node.scheduleRender()
}
Expand Down Expand Up @@ -58,7 +69,9 @@ export class NodeStack {

node.show()

if (prevLead) {
// Only use prevLead for shared element transitions if its instance is still connected.
// In SPA navigations, stale nodes may remain in the stack with disconnected instances.
if (prevLead && prevLead.instance?.isConnected) {
prevLead.instance && prevLead.scheduleRender()
node.scheduleRender()

Expand Down