zank Posted April 19 Share Posted April 19 After having trouble integrating gsap with astro and view transitions (see thread https://discord.com/channels/830184174198718474/1230877829386735616) I think we found a possible way to do it (thanks @Fryuni for the feedback), just wondering if you can confirm I m not missing some holes. So essentially I made a proxy function where all the timelines are stored in a variable and before starting any timeline the method clear is called (https://gsap.com/docs/v3/GSAP/Timeline/clear()/). https://github.com/zanhk/astro-gsap-leak-test/blob/tl-pool/src/scripts/global.ts#L17 import gsap from "gsap"; export const gsapTimelines: { [key: string]: gsap.core.Timeline; } = {}; export function createTimeline( key: string, timelineOptions?: gsap.TimelineVars, ) { const newTimeline = gsap.timeline(timelineOptions); newTimeline.paused(true); gsapTimelines[key] = newTimeline; return newTimeline; } export function loadTimeline( key: string, timelineOptions?: gsap.TimelineVars, ): gsap.core.Timeline { // Here take the timeline from the cache if it exists, otherwise create a new one const timeline: gsap.core.Timeline = gsapTimelines[key] ?? createTimeline(key, timelineOptions); let clearAdded = false; const handler: ProxyHandler<gsap.core.Timeline> = { get(target: gsap.core.Timeline, prop: PropertyKey, receiver: any): any { const origMethod = target[prop as keyof gsap.core.Timeline]; if (typeof origMethod === "function") { return (...args: any[]) => { if (!clearAdded) { target.clear(); clearAdded = true; } const result = origMethod.apply(target, args); if (prop === "play") { clearAdded = false; } // Ensure the proxy maintains the correct this context return result === target ? receiver : result; }; } return origMethod; }, }; return new Proxy(timeline, handler); } Usage https://github.com/zanhk/astro-gsap-leak-test/blob/tl-pool/src/pages/to.astro#L53 loadTimeline("test") .to(".line span", { ease: "power4.out", y: 0, stagger: { amount: 0.3, }, duration: 0.5, }) .play(); That way the heap should be cleared before executing the timeline again. Did I miss something? Thanks Link to comment Share on other sites More sharing options...
zank Posted April 19 Author Share Posted April 19 Also, if you think there is a better way, your suggestions are welcome! Link to comment Share on other sites More sharing options...
Rodrigo Posted April 19 Share Posted April 19 Hi, I don't have any experience with Astro, but maybe you might want to have a look at GSAP Context as it could provide a simpler solution for this: https://gsap.com/docs/v3/GSAP/gsap.context() Now if this approach is not too convoluted (it doesn't really seems like that), does what you need and works well in production, then just use it: "if it ain't broken, don't fix it" 🤷♂️ Happy Tweening! Link to comment Share on other sites More sharing options...
zank Posted April 21 Author Share Posted April 21 Thanks, ended up taking a simpler direction import gsap from "gsap"; export const pool = new Set<gsap.core.Timeline>(); document.addEventListener("astro:after-swap", (ev) => { // after swapping with a new page clean all the timelines for (const timeline of pool.values()) { timeline.kill(); } pool.clear(); }); /** * Add the timeline to the pool so the pool can be used later on * to kill all the timelines so they can be garbage collected */ class TimelineRecycler extends gsap.core.Timeline { constructor(vars?: gsap.TimelineVars, time?: number) { super(vars, time); pool.add(this); } } export default { ...gsap, timeline: (vars?: gsap.TimelineVars) => { return new TimelineRecycler(vars); }, }; Do you know if there is somthing other than timelines that I should "kill" to make sure it get garbage collected before switching to a new page? Thanks Link to comment Share on other sites More sharing options...
Rodrigo Posted April 21 Share Posted April 21 5 hours ago, zank said: Do you know if there is somthing other than timelines that I should "kill" to make sure it get garbage collected before switching to a new page? Ideally every GSAP instance you created Tween/Timeline/ScrollTrigger/Draggable/SpiltText, etc. That's why I suggested the GSAP Context route, you can create/add all those to a GSAP Context instance and when you navigate to another page just use the revert method: const ctx = gsap.context(() => {}); // Throughout your code add GSAP instances to the GSAP Context one // Before changing to a new route ctx.revert(); // BOOM!!! // All the GSAP instances added to that context are killed and reverted // Super simple and easy cleanup, you just worry about writing your code // and create awesome animations, GSAP does the heavy lifting for you. But once again, if what you're doing right now works and makes the most sense to you, then keep it. Happy Tweening! Link to comment Share on other sites More sharing options...
zank Posted April 22 Author Share Posted April 22 Thanks, can I do something like this, where inside the component I add custom logic which is not related to gsap directives? <script> import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText); let ctx: gsap.Context | null = null; function componentVisible() { return document.getElementById("hero") !== null; } document.addEventListener( "astro:page-load", () => { if (componentVisible()) { // Initialize all animations and add all to the current gsap context ctx = gsap.context(() => { // Text + buttons reveal const childSplit = new SplitText( "[data-hero-text-reveal]", { type: "lines", linesClass: "split-child", }, ); const parentSplit = new SplitText( "[data-hero-text-reveal]", { // type: "lines", linesClass: "split-parent", }, ); gsap.timeline() .set("[data-hero-text-reveal]", { opacity: 1 }) .from(childSplit.lines, { yPercent: 300, skewY: 7, stagger: 0.2, }) .to( "[data-hero-reveal]", { opacity: 1, stagger: 0.1, }, "<=", ); const pathsToAnimate = document.querySelectorAll( '[wb-element="path-to-animate"]', ); pathsToAnimate.forEach((path) => { const finalPath = path.getAttribute("wb-final-path"); gsap.timeline({ scrollTrigger: { trigger: path, start: "top bottom", end: "bottom top", scrub: 1, }, }).to(path, { attr: { d: finalPath || "" }, ease: "none", }); }); }); } }, { once: false }, ); document.addEventListener("astro:after-swap", () => { // Before switching to the new page check and clean the current context if (ctx != null) { ctx?.revert(); ctx = null; } }); </script> Also in my case is there any advantage in scoping the context to a specific component? Thanks Link to comment Share on other sites More sharing options...
Rodrigo Posted April 22 Share Posted April 22 Sure! Keep in mind that everything in GSAP (with the exception of our useGSAP hook and PIXI Plugin) is framework agnostic, so we thrive in performance, KB size and flexibility. We don't aim to put constraints for our users or make them code through a bunch of hoops in order to make their code work, we want that they can write their code and that it just works. That's why GSAP Context has the add() method. You can create your context and if you want to create a GSAP instance in a method that you need outside the scope of the GSAP Context instance you can add that instance using the add method: let ctx; ctx = gsap.context(() => {}); const myMethod = () => { // Some logic here ctx.add(() => { // More GSAP instances here }); }; // Then later on your code ctx.revert();// Easy cleanup of EVERYTHING If you want to have some custom logic outside GSAP Context is fine, no problem there. Happy Tweening! Link to comment Share on other sites More sharing options...
zank Posted April 23 Author Share Posted April 23 Thanks, so I cannot do what I did above but I have to call ctx.add(() => { // More GSAP instances here }); Only for when I effectively add a gsap animation to the context, correct? So my previous example would result in <script> import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; gsap.registerPlugin(ScrollTrigger, SplitText); let ctx: gsap.Context | null = null; function componentVisible() { return document.getElementById("hero") !== null; } document.addEventListener( "astro:page-load", () => { if (componentVisible()) { // Initialize all animations and add all to the current gsap context const childSplit = new SplitText( "[data-hero-text-reveal]", { type: "lines", linesClass: "split-child", }, ); const parentSplit = new SplitText( "[data-hero-text-reveal]", { // type: "lines", linesClass: "split-parent", }, ); const pathsToAnimate = document.querySelectorAll( '[wb-element="path-to-animate"]', ); ctx = gsap.context(() => { gsap.timeline() .set("[data-hero-text-reveal]", { opacity: 1 }) .from(childSplit.lines, { yPercent: 300, skewY: 7, stagger: 0.2, }) .to( "[data-hero-reveal]", { opacity: 1, stagger: 0.1, }, "<=", ); }); pathsToAnimate.forEach((path) => { const finalPath = path.getAttribute("wb-final-path"); ctx.add(() => { gsap.timeline({ scrollTrigger: { trigger: path, start: "top bottom", end: "bottom top", scrub: 1, }, }).to(path, { attr: { d: finalPath || "" }, ease: "none", }); }); }); } }, { once: false }, ); document.addEventListener("astro:after-swap", () => { // Before switching to the new page check and clean the current context if (ctx != null) { ctx?.revert(); ctx = null; } }); </script> --- Regarding the scope instead I think that is better to not scope to a specific element as when I switch page the element the context was scoped to would get destroyed and that may cause troble, is my assumption correct? Thanks Link to comment Share on other sites More sharing options...
Rodrigo Posted April 23 Share Posted April 23 Try not to think so much in terms of correct or incorrect, right or wrong. What I suggested was predicated in moving some things outside the GSAP Context scope that maybe don't have to be there, that's all. I was pointing to the fact that you have other options. This boils to personal preference, I like to keep my code as simple as possible while, ideally, avoid methods with large chunks of code that could be difficult to read some time later. You have to find the way that works for you in the best way, nothing more. If your previous code makes more sense to you and works as expected, then use it. If your last code does that, then use that. There is nothing wrong with your first code, with the SplitText instances and the loop inside the GSAP Context scope, that is going to work as you expect, as I mentioned before I was offering some insight that could be useful down the road at some point. Happy Tweening! Link to comment Share on other sites More sharing options...
Solution zank Posted April 23 Author Solution Share Posted April 23 Thanks, If others may need I m currently using import gsap from "gsap"; export class LifecycleManager { public ctx: gsap.Context | null; private boundAfterSwapHandler: () => void; private boundPageLoadHandlers: Map<string, () => void>; constructor() { this.ctx = null; this.boundAfterSwapHandler = this.onChangePage.bind(this); this.boundPageLoadHandlers = new Map(); document.addEventListener("astro:after-swap", this.boundAfterSwapHandler); } /** * Initialize the context */ initializeContext(): void { if (this.ctx === null) { this.ctx = gsap.context(() => {}); } } /** * Check if the component with the given ID exist in the DOM * @param id id of the element * @returns boolean */ elementExists(id: string): boolean { if (!id) throw new Error("ID cannot be null"); return document.getElementById(id) !== null; } /** * Execute the callback when the page is loaded and the component is visible * @param id id of the element * @param callback callback function */ onElementLoaded(id: string, callback: (ctx: gsap.Context | null) => void): void { if (!this.boundPageLoadHandlers.has(id)) { const handler = () => this.onPageLoad(id, callback); this.boundPageLoadHandlers.set(id, handler); document.addEventListener("astro:page-load", handler); } } /** * Callback for the page load event * @param id id of the element * @param callback */ onPageLoad(id: string, callback: (ctx: gsap.Context | null) => void): void { if (this.elementExists(id)) { this.initializeContext(); callback(this.ctx); } } /** * Revert the context */ revertContext() { if (this.ctx !== null) { this.ctx.revert(); this.ctx = null; } } /** * When changing page revert the context * and remove the event listeners */ onChangePage(): void { this.revertContext(); } /** * Cleanup all event listeners */ cleanup(): void { this.revertContext(); document.removeEventListener("astro:after-swap", this.boundAfterSwapHandler); this.boundPageLoadHandlers.forEach((handler, id) => { document.removeEventListener("astro:page-load", handler); }); this.boundPageLoadHandlers.clear(); } } export default LifecycleManager; usage <script> import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { SplitText } from "gsap/SplitText"; import { LifecycleManager } from "@/services"; gsap.registerPlugin(ScrollTrigger, SplitText); let manager = new LifecycleManager(); manager.executeWhenVisible("hero", () => { manager.ctx?.add(() => { const childSplit = new SplitText("[data-hero-text-reveal]", { type: "lines", linesClass: "split-child", }); const parentSplit = new SplitText("[data-hero-text-reveal]", { // type: "lines", linesClass: "split-parent", }); gsap.timeline() .set("[data-hero-text-reveal]", { opacity: 1 }) .from(childSplit.lines, { yPercent: 300, skewY: 7, stagger: 0.2, }) .to( "[data-hero-reveal]", { opacity: 1, stagger: 0.1, }, "<=", ); const pathsToAnimate = document.querySelectorAll( '[wb-element="path-to-animate"]', ); pathsToAnimate.forEach((path) => { const finalPath = path.getAttribute("wb-final-path"); gsap.timeline({ scrollTrigger: { trigger: path, start: "top bottom", end: "bottom top", scrub: 1, }, }).to(path, { attr: { d: finalPath || "" }, ease: "none", }); }); }); }); </script> 1 Link to comment Share on other sites More sharing options...
Recommended Posts
Create an account or sign in to comment
You need to be a member in order to leave a comment
Create an account
Sign up for a new account in our community. It's easy!
Register a new accountSign in
Already have an account? Sign in here.
Sign In Now