Fix stale shared layout nodes during SPA navigations#3543
Conversation
In SPA frameworks, page navigation removes DOM elements externally without going through Motion's projection unmount path. This left zombie projection nodes in the NodeStack, causing broken layout animations when new elements with the same layoutId mounted. Guard against stale nodes in three places: - promote(): skip setting resumeFrom when prevLead has a disconnected instance and no snapshot (zombie from external removal) - relegate(): skip disconnected candidates when finding a new lead - add(): prune zombie members (but preserve lead/prevLead for the current animation cycle) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code ReviewThis PR addresses a critical bug where zombie projection nodes accumulate during SPA navigations when elements are removed externally (outside Motion's unmount path). The solution adds ✅ Code Quality & Best PracticesStrengths:
Minor observations:
🔍 Potential Bugs & Edge CasesWell-handled edge cases:
Questions/Considerations:
⚡ Performance ConsiderationsPositive:
Minimal impact:
🔒 Security ConcernsNo security issues identified. The changes are defensive and:
🧪 Test CoverageExcellent test design:
Suggestions for additional testing:
📋 Additional ObservationsDocumentation:
Example inline comment suggestion for line 15-17: const inst = m.instance as HTMLElement | undefined
// Remove zombie nodes: disconnected DOM elements that bypassed unmount()
// Preserve nodes with snapshots as they may be mid-animation
if (inst && inst.isConnected === false && m.isPresent !== false && !m.snapshot) {
removeItem(this.members, m)
}SummaryThis is a high-quality fix for a subtle but important bug in shared layout animations. The implementation is sound, well-tested, and follows repository conventions. The fix is defensive without being overly aggressive (preserves nodes with snapshots, properly unmounted nodes). Recommendation: Approve ✅ The only suggestion is adding inline documentation for the cleanup logic to help future maintainers understand the nuanced conditions. |
Summary
isConnectedchecks inNodeStack.promote(),relegate(), andadd()to detect and handle stale nodes with disconnected DOM instancesunmount()setsinstance = undefined(safe to use asresumeFrom), while SPA zombies retain a disconnected HTMLElement (stale, must be skipped)Test plan
shared-element-spa-navigation.htmlsimulates SPA navigation (external element removal + animateLayout remount) with two navigation cycles🤖 Generated with Claude Code