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-spa-repeat.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-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
}
}