Skip to content
Closed
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
181 changes: 181 additions & 0 deletions dev/html/public/animate-layout/shared-element-spa-repeat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<html>
<!--
Test for stale shared layout nodes across SPA navigations.
Verifies that resumeFrom is not set to disconnected instances and
that the stack maintains correct references through multiple navigations.
-->
<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 { createNode } = window.Animate
const container = document.getElementById("container")

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

// Simulate first navigation: create element then remove from DOM without cleanup
const staleElement = document.createElement("div")
staleElement.className = "card small"
staleElement.setAttribute("data-layout-id", "hero")
container.appendChild(staleElement)
const staleNode = createNode(staleElement, root, {
layoutId: "hero",
})

// Simulate navigation away: DOM removed but projection node remains in stack
staleElement.remove()

// Simulate second navigation: create exiting element
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",
})

// Mark as exiting and relegate to find previous valid state
exitingNode.isPresent = false
exitingNode.relegate()

// Create current element that should animate from exiting state
const currentElement = document.createElement("div")
currentElement.className = "card big"
currentElement.setAttribute("data-layout-id", "hero")
container.appendChild(currentElement)
const currentNode = createNode(currentElement, root, {
layoutId: "hero",
})

// Test 1: resumeFrom should not point to disconnected stale node
const resumeFrom = currentNode.resumeFrom
const resumeFromConnected = Boolean(
resumeFrom?.instance && resumeFrom.instance.isConnected
)
const resumeFromHasSnapshot = Boolean(resumeFrom?.snapshot)
const resumeFromIsPresent = resumeFrom?.isPresent !== false

// Invalid if: exists, marked as present, but disconnected and no snapshot
const isInvalid =
resumeFrom &&
resumeFromIsPresent &&
!resumeFromConnected &&
!resumeFromHasSnapshot

if (isInvalid) {
currentElement.dataset.layoutCorrect = "false"
console.error(
"resumeFrom points to disconnected zombie instance"
)
}

// Test 2: Verify stack members array doesn't accumulate zombies
// Access internal stack if exposed, or check via root
const stack = currentNode.getStack?.() || currentNode.stack
if (stack) {
const zombies = stack.members.filter(
(m) =>
m.instance &&
m.instance.isConnected === false &&
!m.snapshot &&
m.isPresent !== false
)

if (zombies.length > 0) {
currentElement.dataset.layoutCorrect = "false"
console.error(
`Stack contains ${zombies.length} zombie nodes`
)
}
}

// Test 3: Chain continuity - simulate third navigation
// Remove current node and create a new one to verify the chain isn't broken
if (currentNode.remove) currentNode.remove()

const finalElement = document.createElement("div")
finalElement.className = "card small"
finalElement.setAttribute("data-layout-id", "hero")
container.appendChild(finalElement)
const finalNode = createNode(finalElement, root, {
layoutId: "hero",
})

// Should animate from currentNode (the previous valid lead), not staleNode
const finalResumeFrom = finalNode.resumeFrom
const chainBroken =
finalResumeFrom &&
finalResumeFrom !== currentNode &&
finalResumeFrom === staleNode

if (chainBroken) {
finalElement.dataset.layoutCorrect = "false"
console.error(
"Chain broken: animating from stale node instead of previous"
)
}

// Debug output for CI visibility
window.__testResults = {
resumeFromExists: Boolean(resumeFrom),
resumeFromConnected,
resumeFromHasSnapshot,
isInvalid,
zombieCount: stack
? stack.members.filter(
(m) =>
m.instance &&
m.instance.isConnected === false &&
!m.snapshot &&
m.isPresent !== false
).length
: -1,
chainBroken,
}
</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-after-animate.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-after-animate.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","shared-element-spa-repeat.html"]
149 changes: 69 additions & 80 deletions packages/motion-dom/src/projection/shared/stack.ts
Original file line number Diff line number Diff line change
@@ -1,131 +1,120 @@
import { addUniqueItem, removeItem } from "motion-utils"
import { IProjectionNode } from "../node/types"

interface DOMInstance {
isConnected?: boolean
scheduleRender?: (immediate: boolean) => void
}

export class NodeStack {
lead?: IProjectionNode
prevLead?: IProjectionNode
members: IProjectionNode[] = []

add(node: IProjectionNode) {
const valid = this.members.filter((m) => this.isValid(m))

if (valid.length !== this.members.length) {
this.members = valid
if (this.lead && !valid.includes(this.lead)) {
this.lead = valid[valid.length - 1]
}
if (this.prevLead && !valid.includes(this.prevLead)) {
this.prevLead = undefined
}
}

addUniqueItem(this.members, node)
node.scheduleRender()
}

remove(node: IProjectionNode) {
removeItem(this.members, node)
if (node === this.prevLead) {
this.prevLead = undefined
}
if (node === this.lead) {
const prevLead = this.members[this.members.length - 1]
if (prevLead) {
this.promote(prevLead)
}

if (node === this.prevLead) this.prevLead = undefined
if (node === this.lead && this.members.length) {
this.promote(this.members[this.members.length - 1])
}
}

relegate(node: IProjectionNode): boolean {
const indexOfNode = this.members.findIndex((member) => node === member)
if (indexOfNode === 0) return false

/**
* Find the next projection node that is present
*/
let prevLead: IProjectionNode | undefined
for (let i = indexOfNode; i >= 0; i--) {
const member = this.members[i]
if (member.isPresent !== false) {
prevLead = member
break
}
}
const idx = this.members.indexOf(node)
if (idx <= 0) return false

if (prevLead) {
this.promote(prevLead)
return true
} else {
return false
for (let i = idx; i >= 0; i--) {
if (this.members[i].isPresent !== false) {
this.promote(this.members[i])
return true
}
}
return false
}

promote(node: IProjectionNode, preserveFollowOpacity?: boolean) {
const prevLead = this.lead

if (node === prevLead) return
const prev = this.lead
if (node === prev) return

this.prevLead = prevLead
const canResume = prev && this.isValid(prev)
this.prevLead = canResume ? prev : undefined
this.lead = node

node.show()

if (prevLead) {
prevLead.instance && prevLead.scheduleRender()
if (prev && canResume) {
const prevInstance = prev.instance as DOMInstance
const isConnected = prevInstance?.isConnected !== false

if (!prev.snapshot && isConnected) {
prev.updateSnapshot()
}

if (isConnected) prev.scheduleRender()
node.scheduleRender()

/**
* If both the new and previous lead have the same defined layoutDependency,
* skip the shared layout animation. This allows components with layoutId
* to opt-out of animations when their layoutDependency hasn't changed,
* even when the component unmounts and remounts in a different location.
*/
const prevDep = prevLead.options.layoutDependency
const nextDep = node.options.layoutDependency
const dependencyMatches =
const { layoutDependency: prevDep } = prev.options
const { layoutDependency: nextDep } = node.options

if (
prevDep !== undefined &&
nextDep !== undefined &&
prevDep === nextDep
)
return

if (!dependencyMatches) {
node.resumeFrom = prevLead

if (preserveFollowOpacity) {
node.resumeFrom.preserveOpacity = true
}

if (prevLead.snapshot) {
node.snapshot = prevLead.snapshot
node.snapshot.latestValues =
prevLead.animationValues || prevLead.latestValues
}
node.resumeFrom = prev
if (preserveFollowOpacity) prev.preserveOpacity = true

if (node.root && node.root.isUpdating) {
node.isLayoutDirty = true
}
if (prev.snapshot) {
node.snapshot = prev.snapshot
node.snapshot.latestValues =
prev.animationValues || prev.latestValues
}

const { crossfade } = node.options
if (crossfade === false) {
prevLead.hide()
}
if (node.root?.isUpdating) node.isLayoutDirty = true
if (node.options.crossfade === false) prev.hide()
}
}

exitAnimationComplete() {
this.members.forEach((node) => {
const { options, resumingFrom } = node

options.onExitComplete && options.onExitComplete()

if (resumingFrom) {
resumingFrom.options.onExitComplete &&
resumingFrom.options.onExitComplete()
}
this.members.forEach((n) => {
n.options.onExitComplete?.()
n.resumingFrom?.options.onExitComplete?.()
})
}

scheduleRender() {
this.members.forEach((node) => {
node.instance && node.scheduleRender(false)
this.members.forEach((n) => {
const inst = n.instance as DOMInstance
inst?.scheduleRender?.(false)
})
}

/**
* Clear any leads that have been removed this render to prevent them from being
* used in future animations and to prevent memory leaks
*/
removeLeadSnapshot() {
if (this.lead && this.lead.snapshot) {
this.lead.snapshot = undefined
}
this.lead?.snapshot && (this.lead.snapshot = undefined)
}

private isValid(n: IProjectionNode): boolean {
const inst = n.instance as DOMInstance
const isConnected = inst?.isConnected !== false
return isConnected || n.isPresent === false || !!n.snapshot
}
}