Skip to content

Commit a9a4485

Browse files
committed
fix(core): preserve from-state across frame refresh boundaries
1 parent 21d14b2 commit a9a4485

3 files changed

Lines changed: 375 additions & 10 deletions

File tree

‎packages/core/src/runtime/adapters/gsap.steps.test.ts‎

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,290 @@ describe("re-rendering leaves the rest as the seek left it", () => {
482482
expect(timeline.duration()).toBe(20);
483483
});
484484
});
485+
486+
describe("gsap frame refresh from-state", () => {
487+
it("keeps a from-state when the tween starts between output frames", () => {
488+
const target = { x: 0, pivot: 50 };
489+
const timeline = gsap
490+
.timeline({ paused: true })
491+
.fromTo(
492+
target,
493+
{ x: 10, pivot: 0 },
494+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
495+
0.0995,
496+
);
497+
const adapter = createGsapAdapter({
498+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
499+
});
500+
adapter.seek({ time: 3 / 30 });
501+
expect(target.pivot).toBe(0);
502+
expect(target.x).toBeCloseTo(10.045, 6);
503+
timeline.kill();
504+
});
505+
506+
it("keeps from-only values after a short tween ends within the redraw's rewind", () => {
507+
const target = { x: 0, pivot: 50 };
508+
const timeline = gsap
509+
.timeline({ paused: true })
510+
.fromTo(
511+
target,
512+
{ x: 10, pivot: 0 },
513+
{ x: 100, duration: 0.0002, ease: "none", immediateRender: false },
514+
0.1,
515+
);
516+
timeline.set({}, {}, 1);
517+
const adapter = createGsapAdapter({
518+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
519+
});
520+
adapter.seek({ time: 0.1005 });
521+
expect(target.pivot).toBe(0);
522+
expect(target.x).toBe(100);
523+
timeline.kill();
524+
});
525+
526+
it("restores the normal tween render when replaying a from-state throws", () => {
527+
let pivot = 50;
528+
let rejectFromState = false;
529+
const target = {
530+
x: 0,
531+
get pivot() {
532+
return pivot;
533+
},
534+
set pivot(value: number) {
535+
if (rejectFromState && value === 0) throw new Error("from-state rejected");
536+
pivot = value;
537+
},
538+
};
539+
const timeline = gsap
540+
.timeline({ paused: true })
541+
.fromTo(
542+
target,
543+
{ x: 10, pivot: 0 },
544+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
545+
0.1,
546+
);
547+
timeline.totalTime(0.1, true);
548+
const tween = timeline.getChildren(false, true, false)[0]!;
549+
const renderDescriptor = Object.getOwnPropertyDescriptor(tween, "render");
550+
rejectFromState = true;
551+
const adapter = createGsapAdapter({
552+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
553+
});
554+
expect(() => adapter.seek({ time: 0.1 })).toThrow("from-state rejected");
555+
expect(Object.getOwnPropertyDescriptor(tween, "render")).toEqual(renderDescriptor);
556+
expect(() => adapter.seek({ time: 0.2 })).not.toThrow();
557+
expect(target.x).toBeCloseTo(19, 6);
558+
timeline.kill();
559+
});
560+
561+
it.each([false, true])("restores the tween's render descriptor (own=%s)", (own) => {
562+
const target = { x: 0, pivot: 50 };
563+
const timeline = gsap.timeline({ paused: true });
564+
timeline.fromTo(
565+
target,
566+
{ x: 10, pivot: 0 },
567+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
568+
0.0995,
569+
);
570+
const tween = timeline.getChildren(false, true, false)[0]!;
571+
if (own) {
572+
Object.defineProperty(tween, "render", {
573+
value: tween.render,
574+
writable: true,
575+
configurable: true,
576+
enumerable: false,
577+
});
578+
}
579+
const renderDescriptor = Object.getOwnPropertyDescriptor(tween, "render");
580+
const adapter = createGsapAdapter({
581+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
582+
});
583+
adapter.seek({ time: 0.1 });
584+
expect(target.x).toBeCloseTo(10.045, 6);
585+
expect(Object.getOwnPropertyDescriptor(tween, "render")).toEqual(renderDescriptor);
586+
timeline.kill();
587+
});
588+
589+
it("keeps interpolation when a from-state setter reenters the refresh", () => {
590+
let pivot = 50;
591+
let armed = false;
592+
let entered = false;
593+
const target = {
594+
x: 0,
595+
get pivot() {
596+
return pivot;
597+
},
598+
set pivot(value: number) {
599+
pivot = value;
600+
if (armed && value === 0 && !entered) {
601+
entered = true;
602+
rerenderGsapTimelineAt(timeline, 0.1005);
603+
}
604+
},
605+
};
606+
const timeline = gsap
607+
.timeline({ paused: true })
608+
.fromTo(
609+
target,
610+
{ x: 10, pivot: 0 },
611+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
612+
0.1,
613+
);
614+
timeline.totalTime(0.1005, true);
615+
armed = true;
616+
rerenderGsapTimelineAt(timeline, 0.1005);
617+
expect(entered).toBe(true);
618+
expect(target.pivot).toBe(0);
619+
expect(target.x).toBeCloseTo(10.045, 6);
620+
timeline.kill();
621+
});
622+
623+
it("preserves repeatRefresh function evaluation for very short immediate tweens", () => {
624+
let calls = 0;
625+
const target = { x: 0, pivot: 50 };
626+
const timeline = gsap
627+
.timeline({ paused: true })
628+
.fromTo(
629+
target,
630+
{ x: () => 10 * ++calls, pivot: 0 },
631+
{
632+
x: 100,
633+
duration: 0.0002,
634+
repeat: 2,
635+
repeatRefresh: true,
636+
immediateRender: true,
637+
ease: "none",
638+
},
639+
0.1,
640+
)
641+
.to({}, { duration: 1 });
642+
const adapter = createGsapAdapter({
643+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
644+
});
645+
for (const time of [0, 0.1, 0.1005]) adapter.seek({ time });
646+
expect(target.x).toBe(80);
647+
expect(calls).toBe(6);
648+
timeline.kill();
649+
});
650+
651+
it("reuses the initialized function-valued from-state on repeated seeks", () => {
652+
let calls = 0;
653+
const target = { x: 0, pivot: 50 };
654+
const timeline = gsap.timeline({ paused: true }).fromTo(
655+
target,
656+
{
657+
x: 10,
658+
pivot: () => {
659+
calls++;
660+
return 0;
661+
},
662+
},
663+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
664+
0.1,
665+
);
666+
const adapter = createGsapAdapter({
667+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
668+
});
669+
for (const time of [0.1, 0.1, 0.1005]) adapter.seek({ time });
670+
expect(target.pivot).toBe(0);
671+
expect(calls).toBe(1);
672+
timeline.kill();
673+
});
674+
675+
it.each([
676+
{ label: "exactly at the start", at: 0.1, x: 10 },
677+
{ label: "within the redraw's rewind", at: 0.1005, x: 10.045 },
678+
{ label: "after the redraw's rewind", at: 0.2, x: 19 },
679+
])("keeps from-only values $label and restores them before the tween", ({ at, x }) => {
680+
const target = { x: 0, pivot: 50 };
681+
const timeline = gsap
682+
.timeline({ paused: true })
683+
.fromTo(
684+
target,
685+
{ x: 10, pivot: 0 },
686+
{ x: 100, duration: 1, ease: "none", immediateRender: false, autoRevert: true },
687+
0.1,
688+
);
689+
const adapter = createGsapAdapter({
690+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
691+
});
692+
adapter.seek({ time: at });
693+
adapter.seek({ time: at });
694+
expect(target.pivot).toBe(0);
695+
expect(target.x).toBeCloseTo(x, 6);
696+
adapter.seek({ time: 0.05 });
697+
expect(target.pivot).toBe(50);
698+
expect(target.x).toBe(0);
699+
timeline.kill();
700+
});
701+
702+
it.each([1, 2])("keeps from-only values in a scene playing at %sx", (speed) => {
703+
const target = { x: 0, pivot: 50 };
704+
const scene = gsap
705+
.timeline()
706+
.fromTo(
707+
target,
708+
{ x: 10, pivot: 0 },
709+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
710+
0.1,
711+
)
712+
.timeScale(speed);
713+
const timeline = gsap.timeline({ paused: true }).add(scene, 1);
714+
const adapter = createGsapAdapter({
715+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
716+
});
717+
adapter.seek({ time: 1 + 0.1 / speed });
718+
expect(target.pivot).toBe(0);
719+
expect(target.x).toBeCloseTo(10, 6);
720+
timeline.kill();
721+
});
722+
723+
it.each([false, true])("keeps from-only writers in authored order (set last: %s)", (setLast) => {
724+
const target = { x: 0, pivot: 50 };
725+
const timeline = gsap.timeline({ paused: true });
726+
const set = () => timeline.set(target, { pivot: 42 }, 0.1);
727+
const fromTo = () =>
728+
timeline.fromTo(
729+
target,
730+
{ x: 10, pivot: 0 },
731+
{ x: 100, duration: 1, ease: "none", immediateRender: false },
732+
0.1,
733+
);
734+
if (setLast) {
735+
fromTo();
736+
set();
737+
} else {
738+
set();
739+
fromTo();
740+
}
741+
const adapter = createGsapAdapter({
742+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
743+
});
744+
for (const time of [0.1, 0.1, 0.2]) {
745+
adapter.seek({ time });
746+
expect(target.pivot).toBe(setLast ? 42 : 0);
747+
}
748+
timeline.kill();
749+
});
750+
751+
it("leaves uninitialized scene siblings alone at the scene's opening", () => {
752+
const target = { x: 0 };
753+
const other = { x: 0, pivot: 50 };
754+
const timeline = gsap
755+
.timeline({ paused: true })
756+
.to(target, { x: 100, duration: 10, ease: "none" });
757+
const scene = gsap
758+
.timeline()
759+
.to(target, { x: 200, duration: 1, overwrite: "auto", ease: "none" }, 0);
760+
scene.fromTo(other, { x: 10, pivot: 0 }, { x: 100, duration: 1, immediateRender: false }, 0);
761+
timeline.add(scene, 2);
762+
const adapter = createGsapAdapter({
763+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
764+
});
765+
adapter.seek({ time: 2 });
766+
adapter.seek({ time: 1 });
767+
expect(target.x).toBe(10);
768+
expect(other).toMatchObject({ x: 0, pivot: 50 });
769+
timeline.kill();
770+
});
771+
});

0 commit comments

Comments
 (0)