☰
Motion 共享布局动画“第二次关闭闪烁“问题(issue-2405)验证计划与共享栈状态源码深度解析
2026/10/1 9:42:43 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】motion

A modern animation library for React and JavaScript

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载

本文以仓库内验证型计划 plans/issues/issue-2405.md 为主线,结合 packages/motion-dom/src/projection/shared/stack.ts 与 packages/motion-dom/src/projection/node/create-projection-node.ts 源码,完整剖析 Motion(framer-motion)共享布局动画中"第一次关闭完美、此后每次关闭都出现闪烁/跳动"的历史缺陷:它的症状成因假设、验证优先(verify-first)的执行方法论、可复现测试页与 Cypress 断言设计,以及"无复现即不修复(no repro → no fix)"的仓库治理策略。读完本文,你将掌握如何在 Motion 仓库中为这类疑似已修复(VERIFY-FIXED)的历史 bug 搭建回归验证、写出能在 React 18/19 双版本下稳定判定的 E2E 测试,并理解共享投影节点栈(NodeStack)内部lead/prevLead/snapshot的状态流转。

一、问题背景:issue-2405 是什么

1.1 症状与报告史

issue-2405 于 2023 年 11 月上报(对应 framer-motion 约 10.x 版本):通过共享布局动画(layoutId)打开一个弹窗,然后关闭它——第一次关闭动画完美无瑕,而此后每一次关闭都会出现明显闪烁或跳动。同年 12 月,第二位用户(+1 评论)用"卡片展开(card-expand)"模式复现了同样的问题。

计划文档对症状给出了明确的特征签名:

"first cycle fine, later cycles broken"——首轮正常、后续轮次异常。

这个签名直指共享栈(shared stack)中的陈旧状态(stale state):如果问题源于一次性输入,那么首轮就应该出错;只有"上一轮残留的状态泄漏到下一轮"才能解释"首次完美、之后每次都坏"的规律。

1.2 权威复现描述

issue 正文完整描述了交互流程:

  1. 点击红色方块 → 通过 layout 过渡打开弹窗;
  2. 点击蓝色方块 → 关闭弹窗;
  3. 反复执行上述操作;
  4. 从第 2 次关闭开始出现闪烁。

计划文档指出,这个交互的典型代码模式就是官方文档中的卡片展开(card-expand)范式:

{!open && <motion.div layoutId="card" />} <AnimatePresence> {open && <motion.div layoutId="card" className="overlay" />} </AnimatePresence>

即:未打开时渲染一个小卡片(layoutId="card"),打开时在同一layoutId下渲染覆盖层弹窗,两者通过AnimatePresence与条件渲染完成切换,共享布局动画让元素在"小卡片 ↔ 大弹窗"之间平滑缩放过渡。

二、为什么这是一份"验证优先(VERIFICATION-FIRST)"计划

本计划属于 plans/issues/README.md 中"Verify-then-close queue(验证后关闭队列)"的一员,其分类为VERIFY-FIXED:即有证据表明该 bug 在提交后可能已被修复,但计划要求"必须用复现来证明,而不是假设"。

计划的元信息(Status):

字段值
PriorityP3
EffortM
RiskLOW
Depends onnone
Categorybug(verify-fixed candidate)
Planned atcommit42bfbe3ed,2026-06-11

计划开头还要求执行者先做drift check(漂移检查):

gh api repos/motiondivision/motion/issues/2405 --jq .state

返回必须为open,否则说明 issue 状态已变化、计划前提失效。

2.1 "为什么值得关注"——三个已合入的修复提交

自 2023 年报出后,恰好是共享栈状态这一区域经历了多次实质性修复,因此该 issue 是强 VERIFY-FIXED 候选:

  • 90a3dfbda"Discard zero snapshots (#3030)"(2025-01):丢弃零尺寸快照。现在位于updateSnapshot()中,对应 create-projection-node.ts 的updateSnapshot()方法。
  • 656a77142"Fix stale shared layout nodes during SPA navigations"(2026-02):修复 SPA 导航期间的陈旧共享布局节点。
  • ea1448e4b"actually fix SPA, simplify logic"(2026-02):对 SPA 修复的进一步简化,断连成员(disconnected member)清理逻辑如今位于 stack.ts 的NodeStack.add()中。

正是这些修复与 issue-2405 的症状高度吻合,才让计划将其归类为"验证后关闭",而非"修复"。

三、源码级剖析:共享布局栈 NodeStack 与陈旧状态的来源

要理解为什么"陈旧共享栈状态"会导致第二次关闭闪烁,需要先读懂 Motion 共享布局动画的核心数据结构——NodeStack(节点栈),实现在 packages/motion-dom/src/projection/shared/stack.ts。

3.1 NodeStack 的三个核心字段

export class NodeStack { lead?: IProjectionNode // 当前"领导者":占据该 layoutId 的主导节点 prevLead?: IProjectionNode // 上一任领导者 members: IProjectionNode[] = [] // 该 layoutId 下所有注册的投影节点 }

在共享布局动画中,多个拥有相同layoutId的元素会注册到同一个NodeStack。任意时刻只有一个"主导节点"(lead)真正显示并驱动动画,其他节点被隐藏或跟随;当元素切换(如卡片打开弹窗、弹窗关闭回卡片)时,新旧节点之间通过promote()/relegate()/remove()交接主导权。

3.2add()——断连成员清理(SPA 修复所在)

add(node: IProjectionNode) { addUniqueItem(this.members, node) for (let i = this.members.length - 1; i >= 0; i--) { const member = this.members[i] if (member === node || member === this.lead || member === this.prevLead) continue const inst = member.instance as HTMLElement | undefined if ((!inst || inst.isConnected === false) && !member.snapshot) { removeItem(this.members, member) member.unmount() } } node.scheduleRender() }

关键逻辑(对应计划引用的stack.ts:12-20):新节点入栈时,会反向扫描旧成员,将已从 DOM 断开(isConnected === false)且没有待用快照的成员直接从members中剔除并卸载。这就是提交656a77142/ea1448e4b引入的"断连成员清理",防止已退场(exited)的元素永久残留在栈里。如果这类清理不完整,退场节点仍留在members中,后续轮次就可能被错误地重新提升为主导——这正是"第二次关闭闪烁"的核心嫌疑之一。

3.3remove()/relegate()——主导权交接

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) } } relegate(node: IProjectionNode) { for (let i = this.members.indexOf(node) - 1; i >= 0; i--) { const member = this.members[i] if (member.isPresent !== false && (member.instance as HTMLElement)?.isConnected !== false) { this.promote(member) return true } } return false }
  • remove():节点卸载时将其移出members;如果移除的是lead,则把栈中最后一个成员提升为新主导。
  • relegate():当前主导退位时,向前扫描仍在 DOM 中且isPresent !== false的成员,将找到的第一个提升为新主导。

计划中的假设层级(a)正是指向这里:如果已退场的成员从未离开stack.members,relegate()就会把一个"死节点"提升为主导,导致动画起点错误。

3.4promote()——快照跨轮次泄漏的通道

promote(node: IProjectionNode, preserveFollowOpacity?: boolean) { const prevLead = this.lead if (node === prevLead) return this.prevLead = prevLead this.lead = node node.show() if (prevLead) { prevLead.updateSnapshot() node.scheduleRender() const { layoutDependency: prevDep } = prevLead.options const { layoutDependency: nextDep } = node.options if (prevDep === undefined || prevDep !== nextDep) { node.resumeFrom = prevLead if (preserveFollowOpacity) prevLead.preserveOpacity = true if (prevLead.snapshot) { node.snapshot = prevLead.snapshot node.snapshot.latestValues = prevLead.animationValues || prevLead.latestValues } } if (node.options.crossfade === false) prevLead.hide() } }

这段代码对应计划引用的stack.ts:64-68,是假设层级(b)的核心证据:当新节点node被提升为主导时,如果旧主导prevLead存在快照,新节点会直接拷贝prevLead.snapshot作为自己的动画起点快照。

问题在于:prevLead可能是上一轮残留下来的节点,其snapshot可能携带第 1 轮的旧位置/旧尺寸数据。promote()无条件复用这份快照,就构成了"第 1 轮的快照泄漏进第 2 轮"的潜在通道——新弹窗/新卡片可能从错误的位置起跳,表现为关闭时的"跳变"。

3.5updateSnapshot()——零快照丢弃(#3030 修复所在)

updateSnapshot() { if (this.snapshot || !this.instance) return this.snapshot = this.measure() if ( this.snapshot && !calcLength(this.snapshot.measuredBox.x) && !calcLength(this.snapshot.measuredBox.y) ) { this.snapshot = undefined } }

对应计划引用的create-projection-node.ts:885-897(当前文件中该方法位于 884-896 行):updateSnapshot()在测量后若发现快照的宽高均为零(measuredBox的 x/y 轴长度都为零),则把snapshot置回undefined。这正是提交90a3dfbda(#3030,"Discard zero snapshots")带来的保护——零尺寸快照(例如测量到尚未布局完成的空盒子)绝不能被当作合法动画起点,否则元素会从(0,0)或零尺寸状态起跳,造成明显闪烁。

3.6resumingFrom链——未清除的续接引用

在 create-projection-node.ts 的didUpdate事件处理器中(计划引用553-556,当前文件位于 551-554 行):

if (this.resumeFrom) { this.resumingFrom = this.resumeFrom this.resumingFrom.resumingFrom = undefined }

节点在布局更新时会记录"从谁续接"(resumeFrom/resumingFrom)。计划中的假设层级(c)正是:如果这条续接链在动画结束后没有被正确清除,后续轮次的动画会错误地"接着上一次的节点续接",起点错乱。

3.7 假设层级小结

计划将三个嫌疑按优先级排列,供二分定位时逐一排除:

假设内容对应源码位置
(a)已退场成员从未离开stack.members,relegate()提升死节点stack.ts 的add()/relegate()
(b)第 1 轮快照经promote()拷贝prevLead.snapshot泄漏进第 2 轮stack.ts 的promote()(64-68 行)
(c)resumingFrom续接链未清除create-projection-node.ts 的 didUpdate 处理器(551-554 行)

四、验证执行方法论:五步走(Steps 1–5)

计划将整个验证过程拆成 5 个步骤,每一步都有明确的决策分支。

Step 1:再试一次官方沙盒

两个复现沙盒在计划制定时均不可达(CodeSandbox 的 API/页面被 Cloudflare 403 拦截):

  • codesandbox.io/s/young-tree-wmwqv5(issue 中的沙盒)
  • codesandbox.io/p/devbox/framer-motion-shared-layout-animation-v26vfg(评论中的沙盒)

步骤要求:用 WebFetch/curl 再试一次;若可达则基于真实代码搭建测试页;若仍不可达(预期情况),则按第 2 步的重建方案进行,并在 issue 评论中明确说明。

Step 2:搭建 repeat-toggle 复现(核心实操)

计划要求在 dev/react/src/tests 下新建测试页layout-shared-repeat-toggle.tsx,导出App组件(仓库约定:测试页自动以?test=<test-name>形式可访问,见 CLAUDE.md)。

测试页结构规格:

  • #card:100×100 的motion.div layoutId="popup",在!open时渲染;
  • #overlay:400×400 居中的motion.div layoutId="popup",位于<AnimatePresence>内,在open时渲染;
  • #toggle按钮:翻转open状态;
  • 过渡配置:transition={{ type: "tween", ease: "linear", duration: 0.3 }}——短时长是刻意选择,因为验证需要在一个测试会话内连续跑多个完整开合周期;
  • 完成计数器:通过onLayoutAnimationComplete把完成的周期数写入data-属性,供 spec 确定性等待动画完成,而不是靠猜时长(wait)。
export function App() { const [open, setOpen] = useState(false) const [cycles, setCycles] = useState(0) return ( <div> {!open && ( <motion.div id="card" layoutId="popup" transition={{ type: "tween", ease: "linear", duration: 0.3 }} onLayoutAnimationComplete={() => setCycles((c) => c + 1) // 写入>gh api -X PATCH repos/motiondivision/motion/issues/2405 \ -f state=closed -f state_reason=not_planned
  • 按仓库政策,绝不可把从未在 bug 上失败过的"快乐路径"测试合并进主干——这类测试对回归无防护价值;要么删除,要么以 gist 形式放进评论里存档。

五、可借鉴的既有测试模板:lightbox crossfade

计划明确建议参照已有的共享布局灯箱测试作为模板:

  • 测试页:dev/react/src/tests/layout-shared-lightbox-crossfade.tsx——展示了layoutId双端(画廊项 ↔ 灯箱大图)共享动画的完整写法,包括useIsPresent()控制 overlay 的pointerEvents、id属性供 spec 定位、?instant=/?type=等 URL 参数切换过渡模式;
  • Cypress spec:packages/framer-motion/cypress/integration/layout-shared-lightbox-crossfade.ts——其中expectBbox(element, expectedBbox)精确断言top/left/width/height,并对打开、关闭后的 border-radius、opacity、bbox 逐一校验,是 repeat-toggle spec 断言风格的直接范本。

六、本地运行命令:构建与双版本 Cypress

计划给出命令表:

目的命令预期
构建yarn build(仓库根目录)exit 0
Cypress React 18按 CLAUDE.md 配方:Vite 起dev/react(随机端口)→cypress run --headed --config baseUrl=... --spec cypress/integration/layout-shared-repeat-toggle.ts见步骤
Cypress React 19同 React 18,但用dev/react-19+--config-file=cypress.react-19.json见步骤

CLAUDE.md(108-135 行)给出了可直接执行的完整本地流程(每个 React 版本独立启动 Vite,避免 turbo 端口冲突):

# React 18 PORT=$((10000 + RANDOM % 50000)) cd dev/react && TEST_PORT=$PORT yarn vite --port $PORT & DEV_PID=$! npx wait-on http://localhost:$PORT cd packages/framer-motion && cypress run --headed \ --config baseUrl=http://localhost:$PORT \ --spec cypress/integration/<test-name>.ts kill $DEV_PID # React 19 —— 同样模式,独立端口 PORT=$((10000 + RANDOM % 50000)) cd dev/react-19 && TEST_PORT=$PORT yarn vite --port $PORT & DEV_PID=$! npx wait-on http://localhost:$PORT cd packages/framer-motion && cypress run --config-file=cypress.react-19.json \ --config baseUrl=http://localhost:$PORT --headed \ --spec cypress/integration/<test-name>.ts kill $DEV_PID

要点:每个新 Cypress 测试必须同时通过 React 18 与 React 19(CI 两者都跑);测试必须前台运行(后台运行的 Cypress 会静默挂起且无输出,无法调试)。

七、完成标准与 STOP 条件

7.1 Done criteria(完成标准)

  • 记录复现结论(哪个变体、哪条断言、React 18/19)
  • 若已修复:失败优先(failing-first)测试 + 修复达到可合并状态,所有质量门槛绿色
  • 若无复现:评论草拟并发布,关闭操作以 README 审批为门禁,不提交纯测试 PR
  • 更新 plans/issues/README.md 对应行

7.2 STOP conditions(停止条件)

  • Step 2 可复现,但 Step 3 指向create-projection-node.ts中超出 promote/relegate/snapshot 的动画内部逻辑(>30 行改动)——停止并上报发现(PR #3748/#3749 正在重构该文件);
  • bug 仅在 Cypress/Electron 下间歇性复现——本套件中偶发复现 bug 是已知陷阱(layout-group.ts一族);重跑并上报,而不是提交一个不稳定的测试。

八、维护备注与关联 issue

  • 若 VERIFY-FIXED:在 issue 上注明最可能修复它的提交(90a3dfbda、656a77142、ea1448e4b),让未来的考古排查成本降低;
  • 关联 issue #2338:其症状与 #2405 重叠(卸载/重装跨轮次的陈旧栈状态),若两份计划都执行,可共用测试页模式。

九、方法论总结:从这份计划能学到什么

  1. 验证优先于修复:对"可能已被修好"的 bug,先搭可失败测试证明现状,而不是凭代码阅读下结论——"must be proven with a reproduction, not assumed";
  2. 确定性等待优于猜测等待:用onLayoutAnimationComplete写入data-属性,让测试自行判断动画完成,杜绝脆弱的wait计时;
  3. 中途采样用.then():只有单点采样才能抓住"错误起点"类缺陷,重试型断言会掩盖它们;
  4. 状态泄漏是共享动画 bug 的常见根因:lead/prevLead/snapshot/members的生命周期清理(成员剔除、零快照丢弃、续接链清除)直接决定开合循环动画是否稳定;
  5. 关闭也是流程:任何关闭操作都有审批门禁(APPROVED-CLOSE),且"没失败过的测试不合并"——这是该仓库长期质量的核心纪律。

对于正在排查 Motion 共享布局动画"第 N 次开关异常"问题的开发者,本文提供的测试页结构、源码取证路径(stack.ts 与 create-projection-node.ts)以及执行纪律,可直接迁移到你自己的复现与回归验证工作中。

  • 前端
  • UI组件

【免费下载链接】motion

A modern animation library for React and JavaScript

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载
上一篇:Boss Show Time:终极Chrome插件完整指南,一键显示四大招聘平台职位发布时间
下一篇:CANNBot ascendc-st-design 教程:Ascend C 算子 L0/L1/L2 系统测试用例设计全解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询