Skip to content

Testing and costs

A manual clock under jsdom

anime reads time from Date.now and ticks on requestAnimationFrame. It captures both, and evaluates whether it runs in a browser, when animejs is first imported. A Node test sets the DOM globals and mocks Date before a dynamic import () of anything importing animejs:

import test, { mock } from "node:test";
import { JSDOM } from "jsdom";

const dom = new JSDOM("<!doctype html><body></body>");
globalThis.window = dom.window;
globalThis.document = dom.window.document;
for (const name of ["Node", "Element", "HTMLElement", "SVGElement", "NodeList", "HTMLCollection", "getComputedStyle"])
  globalThis[name] = dom.window[name];
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame = () => 0;
mock.timers.enable({ apis: ["Date"], now: 1000 });

const { checks } = await import("./generated/Checks.js");
for (const [name, check] of checks) test(name, () => check(ms => mock.timers.tick(ms)));

The F# checks, compiled with Fable, switch the engine to manual ticks and step it after each advance of the mocked clock:

open Fable.Core
open Ranvier.Anime

[<Emit("document.body.appendChild(document.createElement($0))")>]
let createElement (tag: string) : TS.Dom.Element = jsNative

[<Emit("$0.style.opacity")>]
let opacityOf (element: TS.Dom.Element) : string = jsNative

/// Advances the mocked clock by the given milliseconds.
type Clock = float -> unit

let linearFade (advance: Clock) =
    Testing.manual ()
    let card = createElement "div"
    Anime.animate (card, Anim (opacity = Value.fromTo (0, 1), duration = 1000, ease = Ease.linear)) |> ignore

    let samples =
        [
            for step in 0..4 do
                advance (if step = 0 then 0. else 250.)
                Testing.step ()
                opacityOf card
        ]

    if samples <> [ "0"; "0.262"; "0.512"; "0.762"; "1" ] then
        failwithf "Unexpected opacities %A" samples

let checks = [| "a linear fade reads the same values every run", linearFade |]
  • Testing.manual () sets Engine.settings.useDefaultMainLoop and pauseOnDocumentHidden to false, which leaves every tick to Testing.step ().
  • Testing.step () advances every running tickable to the current mocked time.
  • Each read runs 12 ms ahead of the mocked time: anime starts a resumed tickable 12 ms early to force a frame on the next tick, so the fade above reads 0.262 after 250 ms, every run.
  • Reactive.policy () is Full under jsdom, which has no matchMedia; a test writes the signal to check the Reduced paths.

jsdom has no layout and no Web Animations. animate, Utils.set, revert, animatables, timelines and a layout's record () run as in a browser. splitText, onScroll and createDraggable need stubs of ResizeObserver, DOMPoint and DOMMatrix, and a FLIP layout's transforms, which run through the Web Animations API, do not move. examples/Ranvier.Motion/tests/pages.test.mjs holds the full set of globals and stubs the example checks run under.

Playwright

In a browser, page.clock controls the same two inputs. Install the clock, paused, before the page's scripts run, so anime captures the controlled Date and frame callback:

const start = new Date("2026-10-21T09:00:00Z").getTime();
await page.clock.install({ time: start });
await page.clock.pauseAt(start + 1);
await page.goto("/kanban/");
await page.clock.runFor(120);   // samples an animation 120 ms in

page.clock.runFor (ms) moves every anime animation by ms. The Web Animations API runs on the browser's own timeline, outside the page clock: a FLIP test asserts that element.getAnimations () holds the layout's animation, calls finish () on it, then asserts the final boxes. examples/Ranvier.Motion/tests/support.mjs holds these steps.

Server checks

A portable template with Motion parts renders on .NET with Server.render. Comparing that HTML with the render of a twin without the parts checks that the parts leave the markup unchanged, as the server checks of the Motion parts do under the default Reject policy. A page with event handlers renders under Omit: the story example's check renders the whole page and its twin with Server.renderDocument and fails when the documents differ.

Bundle costs

Measured with rolldown (minified) and gzip level 9 on anime 4.5.0, importing from animejs. anime's root imports tree-shake, so a bundle holds the features it calls:

Entrygzip
animate12.2 kB
createTimeline13.4 kB
createAnimatable12.6 kB
createLayout20.6 kB
createDraggable19.5 kB
onScroll + animate16.5 kB
splitText3.3 kB
waapi.animate4.5 kB
spring + animate13.1 kB
everything41.0 kB

What Ranvier adds, from the committed measurements (bindings-bundle.json, bundle.json), each checked for drift in CI:

EntryAgainstΔ gzipBudget
Anime.animate (el, Anim (...))the same call written in JavaScript0 B64 B
rows with Motion.presencethe same rows with Hooks.motion and Hooks.exit calling animate by hand+732 B900 B
a list with Motion.flipthe same list with Hooks.reflow calling createLayout by hand+580 B600 B
the view's motion, exit and reflow hook kindsthe view before them, in a bundle using none of them+447 B450 B

The last row is the Playground's sugar-size.mjs explicit entry, recorded in the motion seam note: every view bundle carries it, with or without animation. SVG element creation, the element-name checks and the client element refusals add a few hundred bytes more on the same entry; the motion research note records each measurement.

  • An entry using one Motion part declares exactly the anime feature classes of the functions that part calls (flip brings AutoLayout, WAAPIAnimation and Timeline; scroll ScrollObserver; drag Draggable; split TextSplitter; the lifecycle and reactive parts none).
  • The emitted Ranvier.View.Anime and Fable.Ranvier.Anime modules hold no curry, uncurry, partialApply or createObj.
  • Per instance, each part adds one placed hook and the anime objects it creates; follow, animateOn, draw, morph and drag add one core effect, and a slot adds one cleanup.
Edit this page