
Getting GSAP timelines to survive a Canvas re-render
Scroll-driven motion inside Drupal Canvas, and the teardown step that stops it breaking on edit.
The friction
I wired a scroll-triggered reveal into a Canvas section, nothing fancy, a fade-up on the
heading, the usual ScrollTrigger with a start: 'top 80%'. Worked perfectly the first
time. Then I nudged the section’s spacing preset in the Style panel, Canvas swapped in the
re-rendered markup the way it’s supposed to, and the animation started firing twice.
Nudge it again: three times. By the fifth edit the heading was fading in and out like it
couldn’t decide whether it wanted to exist.
What was actually happening
Canvas edits a section in place. Change a style option, and it doesn’t reload the page:
it re-renders just that section’s markup and swaps it into the DOM, which is exactly the
experience you want as an editor and exactly the thing that will wreck a naive GSAP setup.
My original code ran ScrollTrigger.create() once, on page load, bound to the section
element that existed at that moment. Canvas doesn’t ask GSAP’s permission before
replacing that element. It just replaces it, and the old ScrollTrigger instance doesn’t
know its trigger element is gone. It’s still in ScrollTrigger.getAll(), still listening,
still firing against a DOM node that isn’t attached to the page anymore, right alongside
the brand new ScrollTrigger I’d just created for the section’s replacement.
Every edit added one more zombie instance. Five edits, five ghosts, all watching the same scroll position and firing the same tween against elements in various states of existing.
The fix
The animation setup moved into gsap.context(), scoped to the section, with the context
reverted the instant Canvas signals it’s about to swap that section’s markup:
function bindSectionMotion(section) { const ctx = gsap.context(() => { gsap.from(section.querySelector('.section-heading'), { y: 24, opacity: 0, scrollTrigger: { trigger: section, start: 'top 80%' }, }) }, section)
return ctx}
let ctx = bindSectionMotion(section)
section.addEventListener('canvas:before-rerender', () => { ctx.revert()})
section.addEventListener('canvas:after-rerender', (event) => { ctx = bindSectionMotion(event.detail.section)})ctx.revert() kills every ScrollTrigger and tween the context created and restores the
inline styles GSAP had touched, so the outgoing DOM node isn’t left half-animated before
Canvas discards it. The new section gets its own context on canvas:after-rerender, bound
to the fresh element Canvas actually kept. No zombies, no double-fires, and no slowly
accumulating scroll listener count on a page an editor had been iterating on for twenty
minutes. That last part I hadn’t fully appreciated until I watched it in the profiler.
What I haven’t solved yet
canvas:before-rerender fires for any change to the section, including ones that don’t
touch the elements my animation cares about. Pick a different heading colour and the
whole timeline tears down and rebuilds for no reason. It’s cheap enough that it hasn’t
mattered on anything I’ve built so far, but it’s clearly the wrong granularity, and I
don’t yet have a clean way to ask “did the thing my animation is bound to actually
change” without re-implementing a chunk of DOM diffing myself. If Canvas ever exposes a
more targeted re-render event, this whole file gets simpler. Until then it’s on the list
of a level down.