From 5e67a9d3ebd5a6e41ed1130b357255ab10642740 Mon Sep 17 00:00:00 2001 From: MrVoshel Date: Fri, 30 Jan 2026 13:53:51 +0100 Subject: [PATCH 01/11] Fix: fix simple SPA layout animations Avoid stale shared layout nodes during SPA navigations and add an HTML regression fixture for disconnected resumeFrom cases. --- CHANGELOG.md | 6 + .../shared-element-spa-repeat.html | 110 ++++++++++++++++++ .../fixtures/animate-layout-tests.json | 2 +- .../motion-dom/src/projection/shared/stack.ts | 54 ++++++++- 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 dev/html/public/animate-layout/shared-element-spa-repeat.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 269f11a8c0..060585c362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.30.0] 2026-01-30 + +### Fixed + +- Avoid stale shared layout nodes during SPA navigations. + ## [12.29.2] 2026-01-26 ### Fixed diff --git a/dev/html/public/animate-layout/shared-element-spa-repeat.html b/dev/html/public/animate-layout/shared-element-spa-repeat.html new file mode 100644 index 0000000000..2db5c22c61 --- /dev/null +++ b/dev/html/public/animate-layout/shared-element-spa-repeat.html @@ -0,0 +1,110 @@ + + + + + + +
+ + + + + + + diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index b7090502f4..b1c440faec 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -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"] \ No newline at end of file +["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-spa-repeat.html","shared-multiple-elements.html"] \ No newline at end of file diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index b391c990fc..234c766e5b 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -7,6 +7,36 @@ export class NodeStack { members: IProjectionNode[] = [] add(node: IProjectionNode) { + /** + * Prune disconnected DOM instances to avoid stale stack members + * after SPA-style navigations. + */ + const validMembers = this.members.filter((member) => { + const instance = member.instance as + | { + isConnected?: boolean + } + | undefined + + if (!instance) return false + + return typeof instance.isConnected !== "boolean" + ? true + : instance.isConnected + }) + if (validMembers.length !== this.members.length) { + this.members = validMembers + if (this.lead && !validMembers.includes(this.lead)) { + this.lead = + validMembers.length > 0 + ? validMembers[validMembers.length - 1] + : undefined + } + if (this.prevLead && !validMembers.includes(this.prevLead)) { + this.prevLead = undefined + } + } + addUniqueItem(this.members, node) node.scheduleRender() } @@ -53,13 +83,31 @@ export class NodeStack { if (node === prevLead) return - this.prevLead = prevLead + const prevLeadInstance = prevLead?.instance as + | { + isConnected?: boolean + } + | undefined + const hasConnectedPrevLead = + !!prevLeadInstance && + (typeof prevLeadInstance.isConnected !== "boolean" || + prevLeadInstance.isConnected) + + this.prevLead = hasConnectedPrevLead ? prevLead : undefined this.lead = node node.show() - if (prevLead) { - prevLead.instance && prevLead.scheduleRender() + if (prevLead && hasConnectedPrevLead) { + /** + * Capture the snapshot if we haven't yet. promote() can run before + * willUpdate() during shared transitions. + */ + if (!prevLead.snapshot) { + prevLead.updateSnapshot() + } + + prevLead.scheduleRender() node.scheduleRender() /** From 8ec0c473272d116a0d6280dda2b5b04dc7b320db Mon Sep 17 00:00:00 2001 From: MrVoshel Date: Fri, 30 Jan 2026 16:01:02 +0100 Subject: [PATCH 02/11] Fix shared layout resumeFrom selection Keep exiting nodes with snapshots while filtering stale layout stack members and update the SPA regression fixture accordingly. --- .../shared-element-spa-repeat.html | 9 ++++- .../motion-dom/src/projection/shared/stack.ts | 33 ++++++++++++++----- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/dev/html/public/animate-layout/shared-element-spa-repeat.html b/dev/html/public/animate-layout/shared-element-spa-repeat.html index 2db5c22c61..db0ab42139 100644 --- a/dev/html/public/animate-layout/shared-element-spa-repeat.html +++ b/dev/html/public/animate-layout/shared-element-spa-repeat.html @@ -91,12 +91,19 @@ const resumeFromConnected = Boolean( resumeFrom?.instance && resumeFrom.instance.isConnected ) + const resumeFromHasSnapshot = Boolean(resumeFrom?.snapshot) + const resumeFromIsPresent = resumeFrom?.isPresent !== false const resumeFromInvalid = - resumeFrom && (!resumeFrom.instance || !resumeFromConnected) + resumeFrom && + resumeFromIsPresent && + !resumeFromConnected && + !resumeFromHasSnapshot window.__debugResumeFrom = { exists: Boolean(resumeFrom), connected: resumeFromConnected, invalid: Boolean(resumeFromInvalid), + hasSnapshot: resumeFromHasSnapshot, + isPresent: resumeFromIsPresent, } if (resumeFromInvalid) { diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 234c766e5b..d22267e9a9 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -17,12 +17,19 @@ export class NodeStack { isConnected?: boolean } | undefined + const hasSnapshot = Boolean(member.snapshot) + const isPresent = member.isPresent !== false - if (!instance) return false + if (!instance) { + return !isPresent || hasSnapshot + } + + const isConnected = + typeof instance.isConnected === "boolean" + ? instance.isConnected + : true - return typeof instance.isConnected !== "boolean" - ? true - : instance.isConnected + return isConnected || !isPresent || hasSnapshot }) if (validMembers.length !== this.members.length) { this.members = validMembers @@ -92,22 +99,30 @@ export class NodeStack { !!prevLeadInstance && (typeof prevLeadInstance.isConnected !== "boolean" || prevLeadInstance.isConnected) - - this.prevLead = hasConnectedPrevLead ? prevLead : undefined + const canResumeFrom = Boolean( + prevLead && + (hasConnectedPrevLead || + prevLead.snapshot || + prevLead.isPresent === false) + ) + + this.prevLead = canResumeFrom ? prevLead : undefined this.lead = node node.show() - if (prevLead && hasConnectedPrevLead) { + if (prevLead && canResumeFrom) { /** * Capture the snapshot if we haven't yet. promote() can run before * willUpdate() during shared transitions. */ - if (!prevLead.snapshot) { + if (!prevLead.snapshot && hasConnectedPrevLead) { prevLead.updateSnapshot() } - prevLead.scheduleRender() + if (hasConnectedPrevLead) { + prevLead.scheduleRender() + } node.scheduleRender() /** From 70b552c2272d7a2e708af6c65970156599ac3d37 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 00:03:52 +0100 Subject: [PATCH 03/11] Refactor with simpler and (hopefully) more effective handling logic --- .../motion-dom/src/projection/shared/stack.ts | 173 ++++++------------ 1 file changed, 58 insertions(+), 115 deletions(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index d22267e9a9..4bafc5f94d 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -1,47 +1,23 @@ import { addUniqueItem, removeItem } from "motion-utils" import { IProjectionNode } from "../node/types" +/** + * Manages projection nodes for a single layoutId history. + * Eager cleanup in add() prevents memory leaks; lazy validation in promote() handles SPA race conditions. + */ export class NodeStack { lead?: IProjectionNode prevLead?: IProjectionNode members: IProjectionNode[] = [] add(node: IProjectionNode) { - /** - * Prune disconnected DOM instances to avoid stale stack members - * after SPA-style navigations. - */ - const validMembers = this.members.filter((member) => { - const instance = member.instance as - | { - isConnected?: boolean - } - | undefined - const hasSnapshot = Boolean(member.snapshot) - const isPresent = member.isPresent !== false - - if (!instance) { - return !isPresent || hasSnapshot - } - - const isConnected = - typeof instance.isConnected === "boolean" - ? instance.isConnected - : true - - return isConnected || !isPresent || hasSnapshot - }) - if (validMembers.length !== this.members.length) { - this.members = validMembers - if (this.lead && !validMembers.includes(this.lead)) { - this.lead = - validMembers.length > 0 - ? validMembers[validMembers.length - 1] - : undefined - } - if (this.prevLead && !validMembers.includes(this.prevLead)) { - this.prevLead = undefined - } + this.members = this.members.filter((m) => this.isAlive(m)) + + if (this.lead && !this.members.includes(this.lead)) { + this.lead = undefined + } + if (this.prevLead && !this.members.includes(this.prevLead)) { + this.prevLead = undefined } addUniqueItem(this.members, node) @@ -50,9 +26,11 @@ export class NodeStack { 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) { @@ -65,13 +43,10 @@ export class NodeStack { 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--) { + for (let i = indexOfNode - 1; i >= 0; i--) { const member = this.members[i] - if (member.isPresent !== false) { + if (this.isAlive(member)) { prevLead = member break } @@ -80,98 +55,70 @@ export class NodeStack { if (prevLead) { this.promote(prevLead) return true - } else { - return false } + return false } promote(node: IProjectionNode, preserveFollowOpacity?: boolean) { const prevLead = this.lead - if (node === prevLead) return - const prevLeadInstance = prevLead?.instance as - | { - isConnected?: boolean - } - | undefined - const hasConnectedPrevLead = - !!prevLeadInstance && - (typeof prevLeadInstance.isConnected !== "boolean" || - prevLeadInstance.isConnected) - const canResumeFrom = Boolean( - prevLead && - (hasConnectedPrevLead || - prevLead.snapshot || - prevLead.isPresent === false) - ) - - this.prevLead = canResumeFrom ? prevLead : undefined + this.prevLead = this.isAlive(prevLead) ? prevLead : undefined this.lead = node - node.show() - if (prevLead && canResumeFrom) { - /** - * Capture the snapshot if we haven't yet. promote() can run before - * willUpdate() during shared transitions. - */ - if (!prevLead.snapshot && hasConnectedPrevLead) { - prevLead.updateSnapshot() - } - - if (hasConnectedPrevLead) { - prevLead.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 + if (this.prevLead) { + const prevDep = this.prevLead.options.layoutDependency const nextDep = node.options.layoutDependency - const dependencyMatches = - prevDep !== undefined && - nextDep !== undefined && - prevDep === nextDep - - 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 - } - - if (node.root && node.root.isUpdating) { - node.isLayoutDirty = true - } + + if (prevDep === undefined || nextDep === undefined || prevDep !== nextDep) { + this.setupAnimation(node, this.prevLead, preserveFollowOpacity) } + + this.prevLead.scheduleRender() + } + + node.scheduleRender() + } - const { crossfade } = node.options - if (crossfade === false) { - prevLead.hide() - } + private setupAnimation(node: IProjectionNode, prevLead: IProjectionNode, preserveFollowOpacity?: boolean) { + node.resumeFrom = prevLead + + if (preserveFollowOpacity) { + node.resumeFrom.preserveOpacity = true + } + + if (prevLead.snapshot) { + node.snapshot = prevLead.snapshot + node.snapshot.latestValues = prevLead.animationValues || prevLead.latestValues + } + + if (node.root?.isUpdating) { + node.isLayoutDirty = true + } + + if (node.options.crossfade === false) { + prevLead.hide() } } + private isAlive(node?: IProjectionNode): node is IProjectionNode { + if (!node) return false + if (node.isPresent === false) return true + if (node.snapshot) return true + + const instance = node.instance as { isConnected?: boolean } | undefined + if (!instance) return true + + return instance.isConnected !== false + } + exitAnimationComplete() { this.members.forEach((node) => { const { options, resumingFrom } = node - options.onExitComplete && options.onExitComplete() - if (resumingFrom) { - resumingFrom.options.onExitComplete && - resumingFrom.options.onExitComplete() + resumingFrom.options.onExitComplete && resumingFrom.options.onExitComplete() } }) } @@ -182,10 +129,6 @@ export class NodeStack { }) } - /** - * 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 From fd2eacc60b29c43bd818b8a2c790fd86c6cf3751 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:05:48 +0100 Subject: [PATCH 04/11] Fix react related tests ?? --- .../motion-dom/src/projection/shared/stack.ts | 141 +++++++++--------- 1 file changed, 71 insertions(+), 70 deletions(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 4bafc5f94d..14cbb65b72 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -2,117 +2,99 @@ import { addUniqueItem, removeItem } from "motion-utils" import { IProjectionNode } from "../node/types" /** - * Manages projection nodes for a single layoutId history. - * Eager cleanup in add() prevents memory leaks; lazy validation in promote() handles SPA race conditions. + * Manages temporal history of projection nodes for a single layoutId. */ export class NodeStack { lead?: IProjectionNode prevLead?: IProjectionNode members: IProjectionNode[] = [] + /** + * Add node to stack. Preserves existing leads during filtering to ensure + * shared layout transitions can capture snapshots from unmounting components. + */ add(node: IProjectionNode) { - this.members = this.members.filter((m) => this.isAlive(m)) + this.members = this.members.filter(m => + m === this.lead || m === this.prevLead || this.isAlive(m) + ) - if (this.lead && !this.members.includes(this.lead)) { - this.lead = undefined - } - if (this.prevLead && !this.members.includes(this.prevLead)) { - this.prevLead = undefined - } - addUniqueItem(this.members, node) node.scheduleRender() } + /** + * Remove node. Promotes last member to lead if current lead is removed. + */ remove(node: IProjectionNode) { removeItem(this.members, node) - if (node === this.prevLead) { - this.prevLead = undefined - } - + 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 (prevLead) this.promote(prevLead) } } + /** + * Search backwards for nearest valid ancestor and promote it. + * Used when current lead is being demoted but stack has prior history. + */ relegate(node: IProjectionNode): boolean { - const indexOfNode = this.members.findIndex((member) => node === member) - if (indexOfNode === 0) return false + const index = this.members.findIndex(m => node === m) + if (index <= 0) return false - let prevLead: IProjectionNode | undefined - for (let i = indexOfNode - 1; i >= 0; i--) { - const member = this.members[i] - if (this.isAlive(member)) { - prevLead = member - break + for (let i = index - 1; i >= 0; i--) { + if (this.isAlive(this.members[i])) { + this.promote(this.members[i]) + return true } } - - if (prevLead) { - this.promote(prevLead) - return true - } return false } + /** + * Promote node to lead. Validates previous lead viability lazily to handle + * SPA navigations where previous component unmounted before snapshot capture. + */ promote(node: IProjectionNode, preserveFollowOpacity?: boolean) { const prevLead = this.lead if (node === prevLead) return - this.prevLead = this.isAlive(prevLead) ? prevLead : undefined + this.prevLead = prevLead this.lead = node node.show() - if (this.prevLead) { + if (this.prevLead && this.isAlive(this.prevLead)) { + this.prevLead.scheduleRender() + const prevDep = this.prevLead.options.layoutDependency const nextDep = node.options.layoutDependency - if (prevDep === undefined || nextDep === undefined || prevDep !== nextDep) { - this.setupAnimation(node, this.prevLead, preserveFollowOpacity) + if (prevDep !== undefined && nextDep !== undefined && prevDep === nextDep) { + // Dependencies match: skip shared animation, just render + node.scheduleRender() + return + } + + // Setup shared layout transition + if (this.prevLead.snapshot) { + node.snapshot = this.prevLead.snapshot + node.snapshot.latestValues = this.prevLead.animationValues || this.prevLead.latestValues } - this.prevLead.scheduleRender() + node.resumeFrom = this.prevLead + if (preserveFollowOpacity) node.resumeFrom.preserveOpacity = true + if (node.root?.isUpdating) node.isLayoutDirty = true + + if (node.options.crossfade === false) this.prevLead.hide() } node.scheduleRender() } - private setupAnimation(node: IProjectionNode, prevLead: IProjectionNode, preserveFollowOpacity?: boolean) { - node.resumeFrom = prevLead - - if (preserveFollowOpacity) { - node.resumeFrom.preserveOpacity = true - } - - if (prevLead.snapshot) { - node.snapshot = prevLead.snapshot - node.snapshot.latestValues = prevLead.animationValues || prevLead.latestValues - } - - if (node.root?.isUpdating) { - node.isLayoutDirty = true - } - - if (node.options.crossfade === false) { - prevLead.hide() - } - } - - private isAlive(node?: IProjectionNode): node is IProjectionNode { - if (!node) return false - if (node.isPresent === false) return true - if (node.snapshot) return true - - const instance = node.instance as { isConnected?: boolean } | undefined - if (!instance) return true - - return instance.isConnected !== false - } - + /** + * Notify all members that exit animation completed. + */ exitAnimationComplete() { this.members.forEach((node) => { const { options, resumingFrom } = node @@ -123,15 +105,34 @@ export class NodeStack { }) } + /** + * Schedule render for all members with DOM instances. + */ scheduleRender() { this.members.forEach((node) => { node.instance && node.scheduleRender(false) }) } + /** + * Clear snapshot from current lead to prevent memory leaks. + */ removeLeadSnapshot() { - if (this.lead && this.lead.snapshot) { - this.lead.snapshot = undefined - } + if (this.lead?.snapshot) this.lead.snapshot = undefined + } + + /** + * Determine if node is valid for animation/retention. + * Alive if: exiting (isPresent=false), has snapshot (mid-animation), or connected to DOM. + */ + private isAlive(node?: IProjectionNode): node is IProjectionNode { + if (!node) return false + if (node.isPresent === false) return true + if (node.snapshot) return true + + const instance = node.instance as { isConnected?: boolean } | undefined + if (!instance) return true + + return instance.isConnected !== false } } From 03f8b1b691382408793ab1f144315c0a2473372b Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:46:48 +0100 Subject: [PATCH 05/11] react-related fix 2 drag-test was still failing --- .../motion-dom/src/projection/shared/stack.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 14cbb65b72..5e63926ffb 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -2,7 +2,7 @@ import { addUniqueItem, removeItem } from "motion-utils" import { IProjectionNode } from "../node/types" /** - * Manages temporal history of projection nodes for a single layoutId. + * Tracks history of projection nodes for a single layoutId. */ export class NodeStack { lead?: IProjectionNode @@ -10,14 +10,10 @@ export class NodeStack { members: IProjectionNode[] = [] /** - * Add node to stack. Preserves existing leads during filtering to ensure - * shared layout transitions can capture snapshots from unmounting components. + * Add node to stack. Cleanup happens lazily in promote() to avoid + * interfering with active gestures (e.g., drag) and React render cycles. */ add(node: IProjectionNode) { - this.members = this.members.filter(m => - m === this.lead || m === this.prevLead || this.isAlive(m) - ) - addUniqueItem(this.members, node) node.scheduleRender() } @@ -60,23 +56,21 @@ export class NodeStack { const prevLead = this.lead if (node === prevLead) return - this.prevLead = prevLead + this.prevLead = this.isAlive(prevLead) ? prevLead : undefined this.lead = node node.show() - if (this.prevLead && this.isAlive(this.prevLead)) { + if (this.prevLead) { this.prevLead.scheduleRender() const prevDep = this.prevLead.options.layoutDependency const nextDep = node.options.layoutDependency if (prevDep !== undefined && nextDep !== undefined && prevDep === nextDep) { - // Dependencies match: skip shared animation, just render node.scheduleRender() return } - // Setup shared layout transition if (this.prevLead.snapshot) { node.snapshot = this.prevLead.snapshot node.snapshot.latestValues = this.prevLead.animationValues || this.prevLead.latestValues From f90ecc59eed7e386d22529b905dca13b8f60aea4 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:21:23 +0100 Subject: [PATCH 06/11] Trying a different approach precedent commits that tried to simplify the overall logic broke the stale SPA navigation on SvelteKit locally, so I'm trying with a different approach. The test should probably be revised. --- .../motion-dom/src/projection/shared/stack.ts | 146 ++++++++---------- 1 file changed, 67 insertions(+), 79 deletions(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 5e63926ffb..4895a4fb23 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -1,46 +1,48 @@ import { addUniqueItem, removeItem } from "motion-utils" import { IProjectionNode } from "../node/types" -/** - * Tracks history of projection nodes for a single layoutId. - */ +interface DOMInstance { + isConnected?: boolean + scheduleRender?: (immediate: boolean) => void +} + export class NodeStack { lead?: IProjectionNode prevLead?: IProjectionNode members: IProjectionNode[] = [] - /** - * Add node to stack. Cleanup happens lazily in promote() to avoid - * interfering with active gestures (e.g., drag) and React render cycles. - */ 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.at(-1) + } + if (this.prevLead && !valid.includes(this.prevLead)) { + this.prevLead = undefined + } + } + addUniqueItem(this.members, node) node.scheduleRender() } - /** - * Remove node. Promotes last member to lead if current lead is removed. - */ 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.lead && this.members.length) { + this.promote(this.members[this.members.length - 1]) } } - /** - * Search backwards for nearest valid ancestor and promote it. - * Used when current lead is being demoted but stack has prior history. - */ relegate(node: IProjectionNode): boolean { - const index = this.members.findIndex(m => node === m) - if (index <= 0) return false + const idx = this.members.indexOf(node) + if (idx <= 0) return false - for (let i = index - 1; i >= 0; i--) { - if (this.isAlive(this.members[i])) { + for (let i = idx; i >= 0; i--) { + if (this.members[i].isPresent !== false) { this.promote(this.members[i]) return true } @@ -48,85 +50,71 @@ export class NodeStack { return false } - /** - * Promote node to lead. Validates previous lead viability lazily to handle - * SPA navigations where previous component unmounted before snapshot capture. - */ promote(node: IProjectionNode, preserveFollowOpacity?: boolean) { - const prevLead = this.lead - if (node === prevLead) return + const prev = this.lead + if (node === prev) return - this.prevLead = this.isAlive(prevLead) ? prevLead : undefined + const canResume = prev && this.isValid(prev) + this.prevLead = canResume ? prev : undefined this.lead = node node.show() - if (this.prevLead) { - this.prevLead.scheduleRender() - - const prevDep = this.prevLead.options.layoutDependency - const nextDep = node.options.layoutDependency - - if (prevDep !== undefined && nextDep !== undefined && prevDep === nextDep) { - node.scheduleRender() - return + if (prev && canResume) { + const prevInstance = prev.instance as DOMInstance + const isConnected = prevInstance?.isConnected !== false + + if (!prev.snapshot && isConnected) { + prev.updateSnapshot() } - if (this.prevLead.snapshot) { - node.snapshot = this.prevLead.snapshot - node.snapshot.latestValues = this.prevLead.animationValues || this.prevLead.latestValues + if (isConnected) prev.scheduleRender() + node.scheduleRender() + + const { layoutDependency: prevDep } = prev.options + const { layoutDependency: nextDep } = node.options + + if ( + prevDep !== undefined && + nextDep !== undefined && + prevDep === nextDep + ) + return + + node.resumeFrom = prev + if (preserveFollowOpacity) prev.preserveOpacity = true + + if (prev.snapshot) { + node.snapshot = prev.snapshot + node.snapshot.latestValues = + prev.animationValues || prev.latestValues } - - node.resumeFrom = this.prevLead - if (preserveFollowOpacity) node.resumeFrom.preserveOpacity = true + if (node.root?.isUpdating) node.isLayoutDirty = true - - if (node.options.crossfade === false) this.prevLead.hide() + if (node.options.crossfade === false) prev.hide() } - - node.scheduleRender() } - /** - * Notify all members that exit animation completed. - */ 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?.() }) } - /** - * Schedule render for all members with DOM instances. - */ scheduleRender() { - this.members.forEach((node) => { - node.instance && node.scheduleRender(false) + this.members.forEach((n) => { + const inst = n.instance as DOMInstance + inst?.scheduleRender?.(false) }) } - /** - * Clear snapshot from current lead to prevent memory leaks. - */ removeLeadSnapshot() { - if (this.lead?.snapshot) this.lead.snapshot = undefined + this.lead?.snapshot && (this.lead.snapshot = undefined) } - /** - * Determine if node is valid for animation/retention. - * Alive if: exiting (isPresent=false), has snapshot (mid-animation), or connected to DOM. - */ - private isAlive(node?: IProjectionNode): node is IProjectionNode { - if (!node) return false - if (node.isPresent === false) return true - if (node.snapshot) return true - - const instance = node.instance as { isConnected?: boolean } | undefined - if (!instance) return true - - return instance.isConnected !== false + private isValid(n: IProjectionNode): boolean { + const inst = n.instance as DOMInstance + const isConnected = inst?.isConnected !== false + return isConnected || n.isPresent === false || !!n.snapshot } } From 21111ae1f6655b2ca373a479f49c6d1b9af0e13f Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:57:19 +0100 Subject: [PATCH 07/11] test fix ? --- packages/motion-dom/src/projection/shared/stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 4895a4fb23..3e4a770563 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -114,7 +114,7 @@ export class NodeStack { private isValid(n: IProjectionNode): boolean { const inst = n.instance as DOMInstance - const isConnected = inst?.isConnected !== false + const isConnected = inst ? inst.isConnected !== false : false return isConnected || n.isPresent === false || !!n.snapshot } } From 96cf4ee2c9f872e0b01e3670a9cb6cc8b9ad0e93 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:20:00 +0100 Subject: [PATCH 08/11] whoops --- .../framer-motion/cypress/fixtures/animate-layout-tests.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json index 2efcd6cd8b..b95a4ebe73 100644 --- a/packages/framer-motion/cypress/fixtures/animate-layout-tests.json +++ b/packages/framer-motion/cypress/fixtures/animate-layout-tests.json @@ -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"] From 2d90a5885787398c197cc475edcacdfc4c4b1bd1 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:20:32 +0100 Subject: [PATCH 09/11] revert --- packages/motion-dom/src/projection/shared/stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 3e4a770563..4895a4fb23 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -114,7 +114,7 @@ export class NodeStack { private isValid(n: IProjectionNode): boolean { const inst = n.instance as DOMInstance - const isConnected = inst ? inst.isConnected !== false : false + const isConnected = inst?.isConnected !== false return isConnected || n.isPresent === false || !!n.snapshot } } From 1d713e2554e818d909d1c67366143581df84e3f7 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:07:00 +0100 Subject: [PATCH 10/11] test compatibility fix Cypress might not like ".at(index)", let's see if this passes all the tests. Behaviour in my sveltekit example seem to be correct again. --- packages/motion-dom/src/projection/shared/stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/motion-dom/src/projection/shared/stack.ts b/packages/motion-dom/src/projection/shared/stack.ts index 4895a4fb23..3711794a2b 100644 --- a/packages/motion-dom/src/projection/shared/stack.ts +++ b/packages/motion-dom/src/projection/shared/stack.ts @@ -17,7 +17,7 @@ export class NodeStack { if (valid.length !== this.members.length) { this.members = valid if (this.lead && !valid.includes(this.lead)) { - this.lead = valid.at(-1) + this.lead = valid[valid.length - 1] } if (this.prevLead && !valid.includes(this.prevLead)) { this.prevLead = undefined From 1dbe36f1d1bd5f2166e1d63785f96794be969f40 Mon Sep 17 00:00:00 2001 From: Anthony <54818101+MrVoshel@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:04:22 +0100 Subject: [PATCH 11/11] Make the test more reliable the precedent logic was too flake, commit 03f8b1b in this PR should have failed. Tried making it more extensive (could also be split up is multiple more granular tests if the scope seems too big) --- .../shared-element-spa-repeat.html | 114 ++++++++++++++---- 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/dev/html/public/animate-layout/shared-element-spa-repeat.html b/dev/html/public/animate-layout/shared-element-spa-repeat.html index db0ab42139..174961d279 100644 --- a/dev/html/public/animate-layout/shared-element-spa-repeat.html +++ b/dev/html/public/animate-layout/shared-element-spa-repeat.html @@ -1,7 +1,8 @@