修复 motion 钻石型 MotionValue 链中同帧更新丢失:render-step 删除后执行机制解析
发布时间:2026/10/1 10:18:47来源:尧图网络
前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载导读在 motion 动画库中useTransform/transformValue依赖的推导值如果同时引用某个值及其推导后代形成a → aHalf → aQuarter的钻石型依赖图再计算result f(a, aQuarter)会因推导更新回调在帧循环中被静默丢弃导致结果永久错误而非延迟一帧。本技术指南以 issue-2280.md 修复计划为骨架结合 motion-dom 帧循环与 MotionValue 源码完整还原根因定位、三层失败测试、render-step.ts的先删除再执行修复方案、回归验证与维护边界帮助读者理解帧调度语义并掌握同类同帧重调度问题的排查与修复方法。1. 问题背景一条 2023 年就存在的正确性 BuguseTransformReact 层与transformValuevanilla 层都是把输入 MotionValue 的最新值送入变换函数、再写回输出 MotionValue 的推导机制。当推导链出现钻石形状——即一个结果同时依赖某个原始值以及该原始值的推导后代——时推导计算会使用过期的输入并且永远不会自我纠正。计划文档中给出的最小复现从仓库根目录运行需先执行yarn buildnode -e global.requestAnimationFrame (cb) setTimeout(() cb(performance.now()), 16); const { motionValue, transformValue } require(./packages/motion-dom/dist/cjs/index.js); const a motionValue(0); const aHalf transformValue(() a.get() / 2); const aQuarter transformValue(() aHalf.get() / 2); const result transformValue(() a.get() aQuarter.get()); a.set(100); setTimeout(() console.log(result.get(), aQuarter.get()), 200); # prints: 100 25 —— result 应为 125这里aQuarter正确收敛到25但result卡死在100。该问题自 2023 年提出2024 年注释中又有useScroll的相关报告属于 P1 级别的真实正确性缺陷。计划分类为 FIX优先级 P1工作量 M风险 MED——因为它触碰的是所有调度器共享的核心帧循环。2. 根因定位同帧immediate重调度被 Set 语义吞掉2.1 推导更新的调度路径推导值derived value的更新被调度到frame.preRender步骤并携带immediate标志vanilla 与 React 两层均是如此subscribe-value.tsconst update () outputValue.set(getLatest()) const scheduleUpdate () frame.preRender(update, false, true)use-combine-values.tsuseIsomorphicLayoutEffect(() { const scheduleUpdate () frame.preRender(updateValue, false, true)immediatetrue的含义是如果当前帧正在处理该步骤就把回调加入正在迭代的集合而不是下一帧的队列。2.2 Set 迭代的三个语义陷阱看 render-step.ts 的schedule实现schedule: (callback, keepAlive false, immediate false) { const addToCurrentFrame immediate isProcessing const queue addToCurrentFrame ? thisFrame : nextFrame if (keepAlive) toKeepAlive.add(callback) queue.add(callback) return callback },三个关键语义叠加出 BugSet.prototype.forEach会访问迭代期间新增的成员但一个已经执行过、仍留在集合中的成员不会被再次访问对已存在的成员再次queue.add()是无操作Set 去重。而执行过的回调并不会从thisFrame中被移除直到process()末尾的thisFrame.clear()才清空render-step.ts。这就造成了本帧内想重跑却永远排不上的死角。2.3 钻石链的逐帧推演以a.set(100)为起点按插入顺序逐帧推演a的变化触发update_aHalf与update_result进入下一帧 preRender 队列插入顺序决定执行次序preRender 开始处理update_aHalf执行 →aHalf.set(50)→update_aQuarter被 immediate 调度 → 追加进thisFrame本轮会被访问✓update_result执行 → 读取a100、aQuarter0过期值→result 100update_aQuarter执行 →aQuarter.set(25)→ 其 change 处理器 immediate 调度update_result→thisFrame.add(update_result)无操作它已是成员且已访问过thisFrame.clear()将其丢弃 → 不再有任何调度残留 →result永远卡在100。2.4 为什么没有级联放大MotionValue.updateAndNotify 只在this.current ! this.prev时才通知订阅者第 407 行因此收敛的值图必然终止——回调重跑后输出没有变化就不会再调度任何东西。这也是后续可以安全地让回调同帧重跑的收敛性前提。3. 修复方案先删除、后执行3.1 核心改动修复位于triggerCallbackrender-step.ts在执行回调之前先从thisFrame中删除它function triggerCallback(callback: Process) { /** * Remove before executing so that if this callback is re-scheduled * with immediate during this pass (chained value updates, e.g. * diamond-shaped MotionValue graphs, issue #2280), the re-add * re-enters thisFrame and Set.forEach visits it again this frame. */ thisFrame.delete(callback) if (toKeepAlive.has(callback)) { step.schedule(callback) runNextFrame() } numCalls callback(latestFrameData) }原理依据根据 Set 规范在forEach迭代期间被删除又重新加入的成员会再次被访问。于是上面推演的第 4 步中update_result的 immediate 重调度会重新进入本轮 pass并在同一帧内以aQuarter25重跑一遍result正确收敛到125。3.2 边界与保留项不要删除render-step.ts末尾的thisFrame.clear()render-step.ts——它是防御性清理防止帧循环长时间不运行导致的内存泄漏keepAlive 不受影响step.schedule(callback)默认immediatefalse所以 keepAlive 回调的重加入仍然走nextFrame行为与修复前完全一致范围收敛修复只改动render-step.tssubscribe-value.ts 与 use-combine-values.ts 的调度本身是正确的问题出在 step 层不需要改动batcher.ts的flushNextFrame处理也与本 Bug 无关。计划明确列出了四个改动范围in scope与两个越界out of scope文件角色render-step.ts修复点triggerCallback先删除后执行frameloop/tests/index.test.ts新增机制层测试 1avalue/tests/transform-value.test.ts新增 vanilla 层回归测试 1bvalue/tests/use-transform.test.tsx新增 React 层复现测试 1c4. 三层失败测试先在修复前验证 Bug 存在计划的 Step 1 遵循先写失败测试纪律在三个层次各设一道回归门且要求修复前这三条测试必须失败——若任何一条在修复前通过说明对代码的理解已与实际不符应当 STOP 并重新推导根因。4.1 机制层frameloop 同帧重调度在 frameloop/tests/index.test.ts 中参照已有的fires callback on current frame if scheduled with \true within the same step 测试第 41 行建模it(re-runs a process re-scheduled immediately after it already ran this frame, () { return new Promisevoid((resolve, reject) { const order: string[] [] const b () order.push(b) let rescheduled false const a () { order.push(a) if (!rescheduled) { rescheduled true frame.update(b, false, true) } } frame.update(b) // b inserted before a, so it runs first frame.update(a) frame.render(() order.join() b,a,b ? resolve() : reject(new Error(order.join())) ) }) })修复前期望看到执行序b,a缺少第二次b修复后应为b,a,b。这条测试是执行后重加入可同帧重跑这一机制的守门员。4.2 vanilla 层transformValue 钻石链在 transform-value.test.ts 中复用该文件已有的nextFrame辅助第 5-9 行test(diamond dependencies fully propagate (issue #2280), async () { const a motionValue(0) const aHalf transformValue(() a.get() / 2) const aQuarter transformValue(() aHalf.get() / 2) const result transformValue(() a.get() aQuarter.get()) a.set(100) await nextFrame() expect(aQuarter.get()).toBe(25) expect(result.get()).toBe(125) })修复前result.get()为100修复后为125。它验证的是transformValue依赖收集 subscribeValue调度这条 vanilla 链路的正确性。4.3 React 层issue 原样复现在 use-transform.test.tsx 中使用已导入的../../gestures/__tests__/utils中的nextFrame直接复刻 issue 的场景test(diamond dependency via useTransform chains (issue #2280), async () { let result: MotionValuenumber const Component () { const a useMotionValue(0) const aHalf useTransform(a, (v) v / 2) const aQuarter useTransform(aHalf, (v) v / 2) result useTransform( [a, aQuarter], ([latestA, latestAQuarter]: number[]) latestA latestAQuarter ) useEffect(() { a.set(100) }, []) return motion.div style{{ x: result }} / } render(Component /) await nextFrame() await nextFrame() expect(result!.get()).toBe(125) })注意useTransform的多值形式接收[a, aQuarter]数组与合并函数对应 use-transform.ts 的MultiTransformer重载底层经由 use-combine-values.ts 的useCombineMotionValues订阅输入值并在frame.preRender(updateValue, false, true)上调度。4.4 验证命令用途命令仓库根目录成功预期安装依赖仅在需要时yarn install前台一次exit 0构建yarn buildexit 0motion-dom 测试npx jest --config packages/motion-dom/jest.config.json --testPathPatternframeloop\|transform-value通过framer-motion 测试npx jest --config packages/framer-motion/jest.config.json --testPathPatternuse-transform通过完整 client 套件npx jest --config packages/motion-dom/jest.config.json与cd packages/framer-motion yarn test-client相对 main 无新增失败计划还提示Jest 直接跑源码测试迭代间无需重新构建但frameData.timestamp是跨测试持久化的模块级单例——若新测试只在整套件内表现异常首先怀疑它。5. 回归与提交修复完成后进入 Step 3 的宽范围回归帧循环是一切的地基必须跑全套件——motion-dom 全量 Jest、framer-motiontest-client已知既有失败仅限 SSRTextEncoder与use-velocity、根目录yarn build yarn lint。特别要盯住套件超时/挂起修复允许同帧重执行后一个发散的 immediate 循环会从静默丢弃变为单帧内自旋挂起即 STOP。Git 工作流约定分支为fix/2280-diamond-value-propagation基于main提交信息使用与git log --oneline一致的短祈使句gh pr edit在本仓库因 Projects Classic 弃用而失效需要改 PR 元数据时改用gh api -X PATCH repos/motiondivision/motion/pulls/n。关闭 issue 有明确的门禁只有当 plans/issues/README.md 中本计划行被标记为 APPROVED且修复已合并之后才允许评论并关闭 issue否则将状态置为BLOCKED (awaiting approval)并在 PR 后停止。6. 与计划 011/012 的边界正确性修复 ≠ 推导图重构本修复是让同帧重调度不再被丢弃的正确性补丁不改变推导的拓扑结构因此与两个相邻计划有清晰分工计划 011重写use-combine-values.ts针对每次渲染的重复执行开销不修复本 Bug本计划也不触碰 011 的文件仅共享测试文件可能产生平凡合并冲突。本计划应先落地——它是 motion-dom 的 P1 正确性修复计划 012统一 mark-dirty/pull 推导图012-motion-value-derivation-graph-spike.md设计目标是让钻石型闪烁在结构上不可能。而本修复只是实现最终一致性——result会在同一帧内依次通知100再通知125第 4 步重跑产生双通知并非无闪烁。计划 012 明确要求以本计划新增的三条测试作为其未来设计的回归门。7. 维护要点行为变化的辐射面修复的行为变化范围被刻意限定为回调以immediatetrue重调度进本帧、且本帧已执行过它——以前被丢弃现在会重跑。但辐射面值得评审者留意VisualElement.scheduleRender同样使用 immediate 调度frame.render(this.render, false, true)渲染回调内部修改值后现在会在同一帧内重新渲染而非静默跳过——这是正确行为但值得评审者过目一眼用户自写的非收敛循环如a.on(change) → b.set→b.on(change) → a.set且值发散以前跨帧乒乓或静默死亡现在可能在单帧内自旋。updateAndNotify的相等性截断能终止所有收敛循环若该风险被判定为真实危害需要后续再加最大重访次数守卫——本计划刻意不添加该守卫以控制包体积STOP 条件清单任何 Step 1 测试在修复前通过代码漂移、套件在 Step 2 后挂起/超时、既有 keepAlive 或fires callback on current frame…测试在一次修复尝试后失败、修复被迫改动subscribe-value.ts/use-combine-values.ts/batcher.ts越界应上报——出现任一情况都应停下报告而非硬凑。8. 经验总结Set 语义与帧调度的通用陷阱本案最有迁移价值的教训是三条可复用的调度设计准则执行过但仍在集合中与新增进集合在Set.prototype.forEach下语义不同——前者不会被重访后者会。凡是回调执行过程中可能把自己或同一步骤的兄弟回调immediate 重调度的帧循环都必须先delete再执行否则就会踩中与 #2280 相同的静默丢失Set 去重既是去重也可能是丢更新的元凶——queue.add对已存在成员是 no-op去重语义在排他性队列里是对的但在允许同帧重跑的语义里就会丢帧收敛性保障是允许重跑的前提——updateAndNotify的current ! prev相等性截断value/index.ts保证了值图最终稳定否则同帧重跑会从修复变成放大镜。从源码结构看motion-dom 的帧循环由read/update/preRender/render/postRender五个步骤组成见 frameloop/tests/index.test.ts 第 4-27 行的执行顺序测试推导值更新挂在preRender渲染挂在render——本修复让 preRender 步骤具备了链式推导一帧内收敛的能力也正是钻石链在下一帧呈现正确125的原因。赞分享前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐motion 项目 SVG text MotionValue children 渲染修复全解析从 issue-2578 到 DOMVisualElement 的文本内容同步机制motion 项目 SVG text MotionValue children 渲染修复全解析从 issue 2578 到 DOMVisualElement前端UI组件pnpm 修补包失效Unused Patch检测修复解析删除依赖后不再静默更新锁文件pnpm 修补包失效Unused Patch检测修复解析删除依赖后不再静默更新锁文件 导读 本文讲解 pnpm 在安装过程中针对「修补patch配置的包管理器开发工具CLIMotion 动画库帧循环调度修复让 cancelFrame 对同帧同 Step 内已入队回调即时生效Motion 动画库帧循环调度修复让 cancelFrame 对同帧同 Step 内已入队回调即时生效 本文基于 Motion 仓库中的实现计划 plans/前端UI组件上一篇rhwp-advanced基于 rhwp Rust CLI 的 HWP 布局调试与 IR 结构检视实战指南下一篇local_auth_darwin 版本演进与实现解析Flutter 本地生物认证在 iOS / macOS 的官方实现创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网