- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
本文以仓库内验证型计划 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 正文完整描述了交互流程:
- 点击红色方块 → 通过 layout 过渡打开弹窗;
- 点击蓝色方块 → 关闭弹窗;
- 反复执行上述操作;
- 从第 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):
| 字段 | 值 |
|---|---|
| Priority | P3 |
| Effort | M |
| Risk | LOW |
| Depends on | none |
| Category | bug(verify-fixed candidate) |
| Planned at | commit42bfbe3ed,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 重叠(卸载/重装跨轮次的陈旧栈状态),若两份计划都执行,可共用测试页模式。
九、方法论总结:从这份计划能学到什么
- 验证优先于修复:对"可能已被修好"的 bug,先搭可失败测试证明现状,而不是凭代码阅读下结论——"must be proven with a reproduction, not assumed";
- 确定性等待优于猜测等待:用
onLayoutAnimationComplete写入data-属性,让测试自行判断动画完成,杜绝脆弱的wait计时; - 中途采样用
.then():只有单点采样才能抓住"错误起点"类缺陷,重试型断言会掩盖它们; - 状态泄漏是共享动画 bug 的常见根因:
lead/prevLead/snapshot/members的生命周期清理(成员剔除、零快照丢弃、续接链清除)直接决定开合循环动画是否稳定; - 关闭也是流程:任何关闭操作都有审批门禁(
APPROVED-CLOSE),且"没失败过的测试不合并"——这是该仓库长期质量的核心纪律。
对于正在排查 Motion 共享布局动画"第 N 次开关异常"问题的开发者,本文提供的测试页结构、源码取证路径(stack.ts 与 create-projection-node.ts)以及执行纪律,可直接迁移到你自己的复现与回归验证工作中。
- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
相关推荐
Slate v2 路线图"阶段表述"清理计划:大型迁移项目中文档真相的维护方法
Slate v2 路线图"阶段表述"清理计划:大型迁移项目中文档真相的维护方法 本指南基于 docs/plans/2026 04 07 slate v2 roa
前端UI组件Motion Canvas 状态管理:Variables 类与动画数据共享
Motion Canvas 状态管理:Variables 类与动画数据共享 在 Motion Canvas 动画开发中,如何高效管理跨元素、跨时间线的状态数据是
图形学音视频前端x64dbg StepInto(sti)单步步入命令完全指南:Trap-Flag 单步原理、参数与源码实现
x64dbg StepInto(sti)单步步入命令完全指南:Trap Flag 单步原理、参数与源码实现 本文以 x64dbg 官方命令文档 docs/com
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考