Skip to content

Commit ae868f7

Browse files
committed
feat(motion): 建 GSAP 唯一入口与插件注册
新建 app/src/motion/engine.ts(全仓唯一静态 import GSAP 家族的文件):顶层 gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin, CustomEase);不注册时 useGSAP 防不住 React 19 StrictMode 的双 tween(gsap 双实例 ⇒ context.revert 静默空转)。 engine.test.ts 落 5 条行为判据:StrictMode 单 tween / 卸载清净 / CustomEase / Flip / ScrollToPlugin 各自真的注册(每条各带专属变异体,见 task-3-report.md)。
1 parent 864f488 commit ae868f7

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

‎app/src/motion/engine.test.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context engine.test.ts — 唯一 GSAP 入口 `motion/engine.ts` 的**行为级契约**
4+
* (ADR-035 §2 · 裁决 R2.2 · 计划 T3 的 Verification V2/V3/V4)。
5+
*
6+
* Why 每条判据都在这(编号 ↔ 计划 V 号,逐条各带专属变异体,见 task-3-report.md):
7+
* B1(V2)StrictMode 单 tween:证明顶层 `registerPlugin(useGSAP, …)` 那一行**真的在防** React 19
8+
* StrictMode 的双 tween —— 尖刺 S3.2 的核心发现是「不注册时 `useGSAP` 与裸 `useEffect`
9+
* 表现完全一样」(各留 2 个 tween,且不报错、不警告),所以这条判据是**唯一**能钉住那行的。
10+
* B2(V3)卸载清净:`context.revert()` **同时**清 inline `transform` 与 tween;手写 `tween.kill()`
11+
* 只移除 tween、inline 值会留下(尖刺 S3.3)⇒「无残留」是独立判据,不是 B1 的推论。
12+
* B3/B4/B5(V4)四插件真的注册(第四个 useGSAP 由 B1/B2 覆盖):CustomEase(具名 ease 进得了
13+
* `gsap.parseEase` 的表且曲线值正确)· Flip(`getState` 读得到状态 —— 未注册时 `Flip` 的
14+
* `_toArray` 未注入,直接 `TypeError`)· ScrollToPlugin(元素级 `scrollTop` 真的被写;
15+
* 未注册时 GSAP 只打印 `Invalid property scrollTo … Missing plugin` 并静默不动)。
16+
*
17+
* 判据纪律(R8.1/R8.3):确定性推进**只用** `gsap.timeline({ paused: true })` + `tl.time(t)`;
18+
* 本文件**不**用 `gsap.updateRoot` / `gsap.ticker.tick` / `gsap.ticker.sleep` / `await sleep`,
19+
* **不**把 jsdom 的 `performance` 挂到 `globalThis`(会自调用栈溢出)。断言全部同步。
20+
* 为什么 B3/B4 用 `await import("gsap/CustomEase")` 取句柄:GSAP 家族的**静态** import 只许出现在
21+
* `motion/engine.ts`(R2.1③ / R11.1 的闭包判据),测试要拿插件类只能走**动态** import;
22+
* 动态与静态最终解析到同一个 `node_modules/gsap/<Plugin>.js`(ESM 按 URL 缓存),
23+
* 因此拿到的正是 `engine.ts` 注册过的那一个类对象 —— 这正是本条判据要断的东西。
24+
* 边界:本文件**不**验证 `vendor-gsap` 懒 chunk(那要真实构建产物 ⇒ 归 T4 的 `engine.guard.test.ts`);
25+
* Flip 的**几何位移**在 jsdom 里恒为 0(尖刺 S2.6)⇒ 只断「注册生效」,不断位移量;
26+
* `ScrollToPlugin` 的 `window` 目标读数恒 0(尖刺 S2.7)⇒ 只断元素级。
27+
* 本文件是 `.ts`(照计划 T3 的 Files 表命名)⇒ 用 `createElement` 而不是 JSX 语法
28+
* (仓内同款先例:`ui/primitives/usePresence.node.test.ts` · `views/useViewMemory.test.ts`)。
29+
*/
30+
import { StrictMode, createElement, useRef } from "react";
31+
import type { ReactElement } from "react";
32+
import { render } from "@testing-library/react";
33+
import { describe, expect, it } from "vitest";
34+
import { gsap, useGSAP } from "./engine";
35+
36+
type Timeline = ReturnType<typeof gsap.timeline>;
37+
38+
/** B2 要 seek 的 timeline 句柄:paused timeline 不 seek 就写不出 inline `transform`,判据会假绿。 */
39+
const handles: { timeline: Timeline | null } = { timeline: null };
40+
41+
/** 取最近一次 `useGSAP` 回调建的 timeline;取不到就显式抛(判据不得静默通过)。 */
42+
function lastTimeline(): Timeline {
43+
if (handles.timeline === null) throw new Error("useGSAP 回调没有建立 timeline —— 探针失效");
44+
return handles.timeline;
45+
}
46+
47+
/** B1 探针:根级非 paused tween(读数口径 = tween 数,不 seek)。 */
48+
function StrictProbe(): ReactElement {
49+
const ref = useRef<HTMLDivElement>(null);
50+
useGSAP(() => {
51+
gsap.to(ref.current, { x: 100, duration: 0.5, ease: "none" });
52+
}, []);
53+
return createElement("div", { ref });
54+
}
55+
56+
/** B2 探针:paused timeline(R8.1 唯一正解)—— seek 后 `transform` 有确定值,卸载后必须被清。 */
57+
function PausedProbe(): ReactElement {
58+
const ref = useRef<HTMLDivElement>(null);
59+
useGSAP(() => {
60+
handles.timeline = gsap.timeline({ paused: true });
61+
handles.timeline.to(ref.current, { x: 100, duration: 0.5, ease: "none" });
62+
}, []);
63+
return createElement("div", { ref });
64+
}
65+
66+
/** 造一个游离元素(B4/B5 的载体;不需要挂在 React 树上)。 */
67+
function detachedDiv(): HTMLDivElement {
68+
const el = document.createElement("div");
69+
document.body.appendChild(el);
70+
return el;
71+
}
72+
73+
describe("engine:唯一 GSAP 入口的行为契约", () => {
74+
it("B1 StrictMode 下 useGSAP 只留 1 个 tween(双断言:元素级 + globalTimeline)", () => {
75+
// 本文件第一条用例 ⇒ 全局时间线必须是空的(否则「恰好 1」这条读数会被跨用例残留做假)
76+
expect(gsap.globalTimeline.getChildren()).toHaveLength(0);
77+
78+
const { unmount, container } = render(
79+
createElement(StrictMode, null, createElement(StrictProbe)),
80+
);
81+
const el = container.firstElementChild as HTMLElement;
82+
83+
expect(gsap.getTweensOf(el)).toHaveLength(1);
84+
expect(gsap.globalTimeline.getChildren()).toHaveLength(1);
85+
86+
unmount();
87+
expect(gsap.getTweensOf(el)).toHaveLength(0);
88+
});
89+
90+
it("B2 卸载后 revert 同时清掉 inline transform 与 tween(手写 kill 只清 tween)", () => {
91+
handles.timeline = null;
92+
const { unmount, container } = render(createElement(PausedProbe));
93+
const el = container.firstElementChild as HTMLElement;
94+
95+
lastTimeline().time(0.25);
96+
expect(el.style.transform).toBe("translate3d(50px, 0px, 0px)");
97+
98+
unmount();
99+
expect(el.style.transform).toBe("");
100+
expect(gsap.getTweensOf(el)).toHaveLength(0);
101+
});
102+
103+
it("B3 CustomEase 已注册:具名 ease 进得了 ease 表,且曲线值逐点正确", async () => {
104+
const { CustomEase } = await import("gsap/CustomEase");
105+
CustomEase.create("ed-t3-probe", "0.2,0,0,1");
106+
107+
const ease = gsap.parseEase("ed-t3-probe");
108+
expect(typeof ease).toBe("function");
109+
// 曲线值 = 尖刺 S2.2 的实测读数(t=0.25 ⇒ 60.7029%):证明 ease 真进了 GSAP 的表,
110+
// 而不是「碰巧有个同名函数」。未注册 CustomEase 时 parseEase 返回 undefined ⇒ 本条即红。
111+
expect(ease(0.25)).toBeCloseTo(0.607029, 4);
112+
});
113+
114+
it("B4 Flip 已注册:getState 读得到元素状态(未注册时 _toArray 未注入 ⇒ TypeError)", async () => {
115+
const { Flip } = await import("gsap/Flip");
116+
const el = detachedDiv();
117+
118+
const state = Flip.getState(el);
119+
expect(state).toBeTypeOf("object");
120+
expect(state.targets).toHaveLength(1);
121+
expect(state.targets[0]).toBe(el);
122+
});
123+
124+
it("B5 ScrollToPlugin 已注册:元素级 scrollTo 真的写 scrollTop", () => {
125+
const el = detachedDiv();
126+
expect(el.scrollTop).toBe(0);
127+
128+
const tl = gsap.timeline({ paused: true });
129+
tl.to(el, { scrollTo: { y: 120 }, duration: 1, ease: "none" });
130+
tl.time(0.5);
131+
132+
expect(el.scrollTop).toBe(60);
133+
});
134+
});

‎app/src/motion/engine.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* engine.ts — **全仓唯一 GSAP 入口**(ADR-035 §2 · 裁决 R2.2)。
3+
*
4+
* @ai-context 业务背景:L4 动效层(批 6)的引擎装配点。所有动效落点只许
5+
* `const { gsap, useGSAP } = await import("../motion/engine")` —— 本文件是全仓**唯一**
6+
* 静态 import GSAP 家族(`gsap` / `gsap/*` / `@gsap/react`)的文件。少了这条约束,
7+
* GSAP 会被拉进首屏静态闭包,`vendor-gsap` 独立懒 chunk 的承诺(规格 §13 风险表)当场落空。
8+
* @ai-context 🔴 为什么必须在**顶层**执行 `registerPlugin(useGSAP, …)`(尖刺 S3.2 的发现,本仓已复现):
9+
* 不注册时 `useGSAP()` **防不住 React 19 StrictMode 的双 tween** —— 实测裸 `useEffect` 留
10+
* **2 个** tween,`useGSAP()` **也是 2 个**,卸载后 tween 仍挂在 globalTimeline 上(既不报错也不清理)。
11+
* 根因是 **gsap 双实例**:`gsap` 的 `exports["."]` 把 `import → index.js`(ESM)与
12+
* `require → dist/gsap.js`(CJS)指向**两个物理文件**,而 `@gsap/react@2.1.2` **无 `exports`**
13+
* 字段、`main` 指 UMD/CJS(内部 `require("gsap")`)⇒ Vitest 把依赖 external 给 Node 原生加载后,
14+
* `@gsap/react` 拿到 CJS 运行时、应用代码拿到 ESM 运行时;两个实例各有自己的 `_context`
15+
* ⇒ `context.revert()` **静默空转、无任何警告**。`registerPlugin(useGSAP)` 把 useGSAP 的
16+
* `_gsap` 对齐到本文件的实例 ⇒ 实测 StrictMode **1 个 tween**、卸载后 `transform === ""` 且 tween 数 0。
17+
* 浏览器构建不受影响(Vite 认 `module` 字段)—— 这是**测试底座独有**的坑,验收判据见 `engine.test.ts`。
18+
* @ai-context 插件集合与顺序由 ADR-035 §2 冻结(恰 4 个,`engine.guard.test.ts` 与本节对拍):
19+
* `useGSAP`(React 19 集成)· `Flip`(跨布局形态变化)· `ScrollToPlugin`(时间码回跳)·
20+
* `CustomEase`(双基调的具名 ease;批 6 T8 在此 `CustomEase.create`)。
21+
*
22+
* 副作用:**进程级全局副作用** —— `registerPlugin` 执行后这四个插件对本进程内所有 GSAP 实例可见。
23+
* 测试隔离不受影响(每个测试文件独立环境),但**不得**把本模块当「无副作用工具」在首屏路径上静态引用。
24+
* 边界:本模块**只能经 `await import()` 到达**(R11.1 的闭包判据 = 「静态 import GSAP 家族的文件集合
25+
* ∩ 首屏静态可达闭包 = ∅」);凡需要 `useGSAP` 的组件,其自身必须在动态 import 链上。
26+
* 本文件不做任何动效编排 —— 时长 / 位移 / 具名 ease 分别归 token 真源、`motion/shift.ts` 与 T8。
27+
*/
28+
import { useGSAP } from "@gsap/react";
29+
import { gsap } from "gsap";
30+
import { Flip } from "gsap/Flip";
31+
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
32+
import { CustomEase } from "gsap/CustomEase";
33+
34+
gsap.registerPlugin(useGSAP, Flip, ScrollToPlugin, CustomEase);
35+
36+
export { gsap, useGSAP };

0 commit comments

Comments
 (0)