@zakkster/lite-signal-gsap
v1.0.1
Published
Zero-GC bridge between GSAP and @zakkster/lite-signal. Pool-backed proxy tweens, reactive-target smooth-follow, auto-cleanup inside effects. No per-frame allocations.
Maintainers
Readme
@zakkster/lite-signal-gsap
Zero-GC bridge between GSAP and @zakkster/lite-signal. Drive reactive signal nodes from GSAP tweens through a preallocated proxy pool — no per-frame allocations after warmup.
One pool for the lifetime of the app. No closure per onUpdate. No fresh onUpdateParams array per retarget. No {x, y} object literals churning through the new-space generation. Auto-cleanup when used inside lite-signal effects.
import { signal } from "@zakkster/lite-signal";
import { tweenToSignal } from "@zakkster/lite-signal-gsap";
const cursorX = signal(0);
const cursorY = signal(0);
const playerX = signal(0);
const playerY = signal(0);
window.addEventListener("pointermove", (e) => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
});
// Smooth-follow. Every cursor move retargets the live tween in-place.
tweenToSignal(playerX, cursorX, { duration: 0.3, ease: "power2.out" });
tweenToSignal(playerY, cursorY, { duration: 0.3, ease: "power2.out" });Contents
- Why · Install · Quick start
- How it works
- API reference
- Proxy monomorphism
- Auto-cleanup inside effects
- Testing · Running the demo
- Browser & engine compatibility
- Edge cases & guarantees
- FAQ · License
Why
Reactive state libraries and animation engines are notoriously bad at composing without GC pressure. The naive bridge looks like this, and it's what you write first:
// The code you write first, and regret later
function followCursor(playerX, cursorX) {
let tween = null;
watch(cursorX, (next) => {
if (tween) tween.kill();
tween = gsap.to({ value: playerX.peek() }, {
value: next,
duration: 0.3,
onUpdate() { playerX.set(this.targets()[0].value); }
});
});
}Every cursor move (often 120 Hz on modern displays) allocates:
- a new
{ value }object literal, - a new
onUpdateclosure capturingplayerX, - a new
targetsarray internally in GSAP, - a new tween vars object spread.
For a single follower at 120 Hz, that's ~480 allocations per second. Multiply by 50 followers in a particle system and you're at 24 000 allocations per second from the bridge layer alone, before GSAP even ticks. Major GC pauses turn smooth 60 fps into periodic 30 ms stutters.
flowchart LR
subgraph N["Naive bridge — per retarget"]
direction TB
N1[cursor moves]
N2[allocate proxy object literal]
N3[allocate onUpdate closure]
N4[allocate vars spread object]
N5[GSAP creates new tween]
N6[old proxy + closure<br/>become garbage]
N1 --> N2 --> N3 --> N4 --> N5 --> N6 -.->|GC pressure| N1
end
subgraph B["lite-signal-gsap — per retarget"]
direction TB
B1[cursor moves]
B2[proxy reused from pool]
B3[onUpdate is module-level<br/>no closure]
B4[params array reused<br/>mutate index 1]
B5[GSAP creates new tween]
B5 -.->|no garbage from bridge| B1
B1 --> B2 --> B3 --> B4 --> B5
end@zakkster/lite-signal-gsap owns a preallocated pool of proxy objects, shares a single module-level onUpdate across every tween, and pre-allocates the onUpdateParams array per proxy slot. The bridge contributes zero allocations per frame in steady state. GSAP itself still allocates its own tween objects internally — that's outside our control — but the marginal cost of the bridge layer is gone.
What this is not
- Not a renderer. It draws nothing. You bring the canvas, WebGL, DOM, or whatever.
- Not a tween engine. GSAP is the engine. This library is plumbing.
- Not an easing library. GSAP accepts
(progress: number) => numbernatively, so@zakkster/lite-easecurves work without a bridge function. Strict modular boundaries. - Not magic. Hand-rolled
gsap.to(myObject, ...)with your own pool is faster (no Set lookups forkillTweensOf, no generation guard). This library trades a small constant for safe auto-cleanup + a managed pool + a typed API in ~250 lines of code.
Install
npm i @zakkster/lite-signal-gsap @zakkster/lite-signal gsapESM-only. Ships TypeScript definitions alongside the source. GSAP and @zakkster/lite-signal are peer dependencies — install at the versions your app uses.
import {
tweenSignal,
tweenToSignal,
tweenRecordSignal,
killTweensOf,
isTweening,
configure,
stats
} from "@zakkster/lite-signal-gsap";Quick start
Scalar tween — fire-and-forget
import { signal } from "@zakkster/lite-signal";
import { tweenSignal } from "@zakkster/lite-signal-gsap";
const opacity = signal(0);
tweenSignal(opacity, 1, { duration: 0.5, ease: "power2.out" });The returned handle is a callable stop function with .tween attached for advanced control. Calling stop() kills the tween and releases the proxy slot. It is idempotent.
const stop = tweenSignal(playerX, 500, { duration: 2 });
stop.tween.pause(); // pause via the underlying GSAP tween
stop.tween.timeScale(0.5); // half speed
stop(); // kill + releaseReactive-target tween — smooth follow
tweenToSignal is the differentiator. The live tween retargets in-place whenever the target signal changes.
import { signal } from "@zakkster/lite-signal";
import { tweenToSignal } from "@zakkster/lite-signal-gsap";
const cursorX = signal(0);
const playerX = signal(0);
tweenToSignal(playerX, cursorX, {
duration: 0.25,
ease: "power2.out",
onSettle: () => console.log("caught up")
});onSettle fires each time the follower catches the target and no retarget is pending. onComplete and onInterrupt from vars are deliberately stripped — they would fire per-overwrite, which is rarely what you want.
Record tween — many signals, one ticker
const t = { x: signal(0), y: signal(0), scale: signal(1) };
tweenRecordSignal(t, { x: 100, scale: 1.5 }, { duration: 1 });
// ^ y is left untouchedOne GSAP tween, one onUpdate, three signal.set calls per frame. Each signal propagates independently — downstream computeds that read only t.x won't re-evaluate when t.scale changes.
How it works
The proxy pool
GSAP tweens a plain object's numeric properties. The bridge owns a preallocated pool of these objects (proxies) and claims one per tween.
flowchart TB
subgraph P["Proxy pool — preallocated, reused forever"]
direction LR
P0["Slot 0<br/>{ gen, value, _sig, params }"]
P1["Slot 1<br/>{ gen, value, _sig, params }"]
P2["Slot 2<br/>{ gen, value, _sig, params }"]
P3["..."]
end
P -.->|claim on tween creation| T[live GSAP tween]
T -.->|release on completion or kill| PEach slot carries:
gen— a monotonically-increasing integer bumped on every claim and every retarget. The shared onUpdate compares the proxy's current gen against the gen captured at tween-creation time; a stale fire from an overwritten tween is rejected before it can corrupt the signal.value— the property GSAP animates. For scalar tweens._sig— the target signal, set on claim, nulled on release._sigs— the target record (fortweenRecordSignal), nulled on release.params— a 2-slot array[proxy, gen]passed to GSAP asonUpdateParams. Mutated on retarget rather than reallocated.
The hot path
Every running tween's onUpdate fires through a single module-level function:
function sharedOnUpdate(proxy, expectedGen) {
if (proxy.gen !== expectedGen) return; // stale fire — bail
proxy._sig.set(proxy.value);
}No closure. No this. No argument shuffling. V8 inlines this aggressively. The onUpdateParams: proxy.params reference is mutated in place across retargets:
proxy.gen++;
proxy.params[1] = proxy.gen; // tells the next onUpdate which gen is "live"
gsap.to(proxy, { ..., onUpdateParams: proxy.params });GSAP reads onUpdateParams at every fire, so the mutation propagates immediately. The previous tween has been killed by overwrite: "auto" — its parameters are dead.
Tween lifecycle
sequenceDiagram
participant U as user code
participant B as bridge
participant P as pool
participant G as GSAP
U->>B: tweenSignal(s, 100)
B->>P: claimProxy()
P-->>B: proxy (gen++)
B->>G: gsap.to(proxy, vars)
G-->>B: tween
B-->>U: stop handle (callable)
Note over G: per-frame onUpdate fires<br/>proxy.value mutates<br/>shared handler does s.set(value)
G->>B: onComplete fires
B->>B: stop() called internally
B->>G: tween.kill()
B->>P: releaseProxy()API reference
tweenSignal(targetSignal, targetValue, vars?)
Tween a numeric signal toward a target value.
| Parameter | Type | Notes |
|---|---|---|
| targetSignal | Signal<number> | The signal to animate. |
| targetValue | number | End value. |
| vars | SignalTweenVars | GSAP TweenVars (duration, ease, delay, repeat, yoyo, callbacks…). |
Returns a callable stop handle with .tween attached.
tweenToSignal(outputSignal, targetSignal, vars?)
Tween outputSignal toward a reactive target. The live tween retargets via GSAP's overwrite: "auto" whenever any signal read by targetSignal changes.
| Parameter | Type | Notes |
|---|---|---|
| outputSignal | Signal<number> | The signal the bridge writes to. |
| targetSignal | Signal<number> | () => number | Reactive driver. May be a signal or any function that reads signals. |
| vars | SignalToVars | Like SignalTweenVars but without onComplete / onInterrupt. Use onSettle instead. |
onSettle fires when the live tween reaches its target without being interrupted by a retarget. May fire multiple times over a watcher's lifetime if the target stays still long enough between retargets.
⚠️ Do not pass a targetSignal that transitively reads outputSignal — that creates an infinite reactive loop.
tweenRecordSignal(signalRecord, targets, vars?)
Tween a plain record of numeric signals as one GSAP tween.
| Parameter | Type | Notes |
|---|---|---|
| signalRecord | Record<string, Signal<number>> | E.g. { x: signal(0), y: signal(0) }. |
| targets | Record<string, number> | Only listed keys are animated; others are left untouched. |
| vars | SignalTweenVars | GSAP TweenVars. |
killTweensOf(target?)
Kill every live tween targeting target. Accepts a scalar signal or a record. Omit target to kill every tween managed by the bridge.
isTweening(target)
Returns true if the bridge owns a live tween for target. Synchronously accurate from the moment of creation — does not wait for GSAP's first tick. Membership reflects the bridge's proxy lifecycle: a tween enters on creation and leaves on natural completion, interruption, explicit kill, or killTweensOf.
This differs subtly from gsap.isTweening, which is tick-gated and returns false for paused or delayed tweens. For a lifecycle-aware bridge check, Set membership is the more useful semantic. For a strict "actively rendering right now" check, use the handle's .tween.isActive() directly.
configure(opts)
| Option | Type | Default | Notes |
|---|---|---|---|
| proxyPoolSize | number | 64 | Initial pool capacity. |
| growthPolicy | "throw" | "grow" | "throw" | Behavior when the pool is exhausted at poolHighWaterMark. |
| defaults | SignalTweenVars | {} | Default vars merged into every call (call-site vars take precedence). |
Locks after the first tween. Calling configure later throws. Set this at module init.
stats()
Returns a snapshot:
| Field | Type | Meaning |
|---|---|---|
| liveTweens | number | Tweens currently running. |
| pooledProxies | number | Slots available in the pool. |
| proxyPoolCapacity | number | Configured ceiling (throw-policy) or initial (grow-policy). |
| poolHighWaterMark | number | Lifetime peak of allocated slots. Never decreases. |
| retargetCount | number | Lifetime count of tweenToSignal retargets. |
Proxy monomorphism
To maintain zero-allocation hot paths, lite-signal-gsap reuses a preallocated pool of proxy objects across tweens. When you use tweenRecordSignal, the bridge dynamically assigns your record's properties (e.g., x, y) directly onto a pooled proxy.
When the tween finishes, the proxy is returned to the pool. To avoid GC pressure, the bridge does not delete the old keys; it merely nullifies their reactive links (proxy._sigs = null, values reset to 0).
If you subsequently claim that same slot for a different shape (e.g. r, g, b, a), the proxy object accumulates all keys (x, y, r, g, b, a). Over time, this forces V8's Inline Caching (IC) to treat the proxy as a polymorphic dictionary rather than a fast-path monomorphic struct.
The no-downside case. A pool that only ever sees one record shape stays fully monomorphic forever. The IC for proxy._sigs[key].set(proxy[key]) resolves on the first frame and never invalidates. This is the common case — most apps animate one or two stable shapes ({x, y} for 2D position, {r, g, b, a} for colors).
The constraint. For extreme performance targets (e.g. 120 fps WebGL particle orchestration with thousands of simultaneous record tweens), avoid mixing wildly different record shapes through the same pool. Either:
- stick to a few consistent structural shapes across your app, or
- split disjoint fields into separate scalar
tweenSignalcalls (the scalar proxies don't carry user-defined keys — they only havevalue,gen, and bridge-internal fields, so they stay monomorphic regardless).
For typical UI animation this is irrelevant. The penalty is measurable only in tight inner loops with thousands of operations per frame.
Auto-cleanup inside effects
Every tween constructor calls onCleanup(stop) unconditionally. lite-signal's onCleanup is a silent no-op outside a tracking context, so the call is always safe.
Inside an effect, the tween is killed when the effect re-runs or disposes:
import { signal, effect } from "@zakkster/lite-signal";
import { tweenSignal } from "@zakkster/lite-signal-gsap";
const alarm = signal(false);
const opacity = signal(0);
const dispose = effect(() => {
if (alarm()) {
// Auto-killed if `alarm` flips back to false before this completes.
tweenSignal(opacity, 1, { duration: 0.5 });
}
});
alarm.set(true); // opacity tweens to 1
alarm.set(false); // tween is killed; opacity stays wherever it was
dispose(); // any live tweens from the effect are killedThis pattern is how the demo manages its follower set — change the ease selection and the entire ensemble is rebuilt by a single effect dispose + recreate, with no manual per-follower stop() calls.
Outside any observer, the tween's lifetime is yours to manage via the returned stop handle.
Testing
npm test
# or, for the zero-allocation heap-budget check:
npm run test:gcThe test suite uses node:test (built into Node 18+) with --test-reporter=spec. It covers:
| Group | What's tested |
|---|---|
| tweenSignal | basic to-target, handle.tween escape hatch, onComplete chaining, proxy release on natural completion + on kill, handle idempotency |
| Auto-cleanup | tween killed on effect dispose, prior tween killed on effect re-run, no-op outside tracking |
| tweenToSignal | retargets on target change, settles correctly, onSettle fires on settle and NOT on retarget, kill stops watcher + tween, accepts both Signal and () => number |
| tweenRecordSignal | multi-field animation, only-listed-keys-animate, proxy release |
| killTweensOf / isTweening | scalar match, record match, kill-all behavior, false for untracked |
| configure | locks after first use |
| stats | live-tween count accuracy, monotonic high-water-mark, retargetCount tracking |
| Zero-allocation (gated on --expose-gc) | 10 000 retargets keep heap growth under 2 MB |
A clean run ends with all tests passing and exit code 0. Suitable for CI.
The test strategy uses tween.progress(1) to fast-forward specific tweens deterministically — no real-time waits, no flaky timing assertions. The GSAP ticker still runs but the tests don't depend on it.
Running the demo
The demo is in demo/:
demo/index.html # importmap + DOM shell
demo/main.js # the actual demoA "phase chase" visualization: a configurable set of followers each tweenToSignal toward the cursor, each with a different GSAP ease curve, so you can see the difference between power2.out, elastic.out, bounce.out, and friends side-by-side. The HUD reads stats() live — retargets/s will pin to your pointermove rate (typically 60–120 Hz on modern displays) while fps stays at the display refresh, which is the visual proof of the zero-GC claim.
The demo imports gsap and @zakkster/lite-signal from a CDN via <script type="importmap">, and imports @zakkster/lite-signal-gsap from ../SignalGsap.js (relative path to the source). No inlined library code in the demo. The import map is the integration test for the published ESM surface.
Because the demo loads ../SignalGsap.js over a relative path, you need a local server:
npx serve
# then open http://localhost:3000/demo/file:// works in some browsers but not all; a server is the reliable path.
Browser & engine compatibility
The library is plain ESM with no DOM dependencies. Anywhere GSAP runs, this runs.
| Target | Library | Demo (canvas + importmap) | |---|---|---| | Chrome / Edge 89+ | ✅ | ✅ | | Firefox 108+ | ✅ | ✅ (importmap support) | | Safari 16.4+ | ✅ | ✅ | | Node.js 18+ | ✅ | — (browser only) | | Bun / Deno | ✅ | — |
Older browsers without import-map support can use the library directly — the demo is what relies on import maps for clean CDN imports.
Edge cases & guarantees
Behaviors the test suite pins down:
stop()is idempotent and re-entrancy-safe. Calling it twice is a no-op. Calling it from inside anonCompletecallback is safe — the bridge already called it before invoking your callback.stop.tweenis reassigned on every retarget intweenToSignal. It always references the currently running tween, never a stale one. If you grab the reference, retarget, then try to pause your grabbed reference, you'll be pausing a killed tween — re-readstop.tweenafter retargets.- Generation guards reject stale onUpdate fires. A tween killed by
overwrite: "auto"whose final frame is still in GSAP's queue cannot write to a signal that has since been re-assigned. The gen counter on the proxy is bumped before the new tween starts; the oldonUpdateParams[1]no longer matches, so the shared onUpdate bails early. onCleanup(stop)is unconditional. Safe outside tracking; auto-fires inside an effect / computed / watch. Noif (isTracking())guard in user code.tweenToSignalstrips per-tweenonCompleteandonInterrupt. They would fire per overwrite, which is rarely what users want. UseonSettlefor "follower caught the target" andstopfor end-of-life.tweenRecordSignalonly animates keys present intargets. Fields in the record without target values are left at their current signal value; the bridge does not touch them.configurelocks after the first tween. This prevents foot-guns where pool sizes change underneath running tweens. Set it once at app init.poolHighWaterMarknever decreases. It's a lifetime metric for diagnostics. UsepooledProxiesfor instantaneous availability.growthPolicy: "throw"throws synchronously when the next claim would exceedpoolHighWaterMark. The error is recoverable — your code throws, the tween is never created, no proxy is leaked.isTweeningreturns true for the entire bridge lifetime of a tween, including paused, delayed, and between-retarget states. It is synchronously correct from creation and does not wait for GSAP's first tick. For a strict "actively rendering right now" check matchinggsap.isTweeningsemantics, usehandle.tween.isActive()directly.
FAQ
Why a proxy pool? Can't I just gsap.to({}, ...) per tween?
You can — and for a handful of tweens, you should. The pool exists for two reasons: (1) zero allocation per construction at scale, (2) bookkeeping for killTweensOf / isTweening / auto-cleanup. The pool's lookup overhead is a small constant; the alternative is per-tween closures and per-tween object literals, which scale poorly under load.
Why no signalEase() bridge for @zakkster/lite-ease?
GSAP accepts custom ease functions in the shape of (progress: number) => number. @zakkster/lite-ease produces exactly that shape. They compose natively — adding a wrapper would be ceremony with no value. Strict modular boundaries.
Can I use a GSAP Timeline?
Not directly in v1.1 — there's no signalTimeline constructor yet. If you need timeline behavior, build the timeline with gsap.timeline() directly and add your signal tweens to it through tl.add(tweenSignal(s, target, vars).tween, position). The .tween escape hatch on every handle gives you full GSAP API access.
Can I tween non-numeric properties?
The bridge writes through signal.set(proxy.value) where proxy.value is whatever GSAP wrote. GSAP only knows how to interpolate numbers and color strings. If your signal carries a non-numeric value, this library is not the right tool — interpolate manually in an effect.
Why does tweenToSignal strip onComplete and onInterrupt?
Because in a retargeting watcher, the per-tween onComplete fires every time the target stays still long enough for the live tween to settle, and onInterrupt fires on every overwrite. Both are confusing under the "watch a moving target" mental model. onSettle is the cleanly-named replacement for the only case users actually want: "the follower caught up". For end-of-life, the returned stop handle is unambiguous.
Does it leak in HMR / dev reloads?
The bridge holds tween references for the lifetime of the live tweens. On module reload, the old GSAP ticker keeps running tweens against signals that no longer exist (their references in the new module are different). For dev safety, call killTweensOf() (no argument) in your HMR dispose hook, or scope your bridge usage inside effects so HMR cleanup tears them down.
What's the bundle cost?
Single-file ESM, ~250 lines, ~6 KB minified before peer-dep counting. gsap and @zakkster/lite-signal are peer dependencies — they're not in the package weight.
Does it work in a Web Worker?
GSAP itself doesn't need DOM, but its default ticker uses requestAnimationFrame which isn't available in workers. You can swap GSAP's ticker for setInterval-based ticking and the bridge will work, but this is GSAP territory, not bridge territory.
License
MIT © Zahary Shinikchiev
