Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .changeset/lazy-otters-shave.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,3 @@ the listeners down:
<!-- after -->
<div {@attach enabled ? draggable() : undefined}>…</div>
```

25 changes: 25 additions & 0 deletions .changeset/soft-pianos-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@ixirjs/pulse': minor
---

Move transform animations off the main thread.

`animate()` drives transforms through CSS custom properties so independent
animations compose on one element, but Chromium cannot run a custom-property
animation on the compositor, and one such property pins everything animating
alongside it. A dialog morph was a style recalc and a full repaint every frame.

Three changes, all automatic and with no new options:

- Transform-animated elements get a `will-change` hint while an animation is in
flight, so they are no longer repainted every frame. Every gesture takes the
same hint for as long as it is writing, and hands it back after.
- When nothing else is composing on an element, the variable keyframes are
folded into real `translate` / `scale` / `rotate` keyframes. The fold reverses
itself the moment anything else touches the element, so composition is
unchanged.
- Props are no longer grouped across the compositability boundary, so a
`width` can no longer drag a sibling `opacity` onto the main thread.

One visible consequence: an `animate()` call mixing compositable and layout
properties now produces two entries in `controller.animations` instead of one.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# Generated code graph (graphify update .)
graphify-out
6 changes: 3 additions & 3 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
{
"name": "Root public API",
"path": "./dist/index.js",
"limit": "19 kB"
"limit": "20 kB"
},
{
"name": "animate only",
"path": "./dist/animate/index.js",
"limit": "8 kB"
"limit": "9 kB"
},
{
"name": "flip only",
"path": "./dist/flip/index.js",
"limit": "7 kB"
"limit": "9 kB"
}
]
316 changes: 34 additions & 282 deletions bun.lock

Large diffs are not rendered by default.

66 changes: 63 additions & 3 deletions src/lib/animate/core/animate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@
import { createController, noopController } from './controller';
import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes';
import { normalizeInput } from '../keyframes/normalize';
import { deregisterTransformAnimation, registerTransformAnimation } from '../properties/properties';
import {
demoteFoldedTransforms,
deregisterTransformAnimation,
hasActiveTransforms,
registerFoldedTransforms,
registerTransformAnimation,
type FoldedTransform
} from '../properties/properties';
import {
ensurePropertiesRegistered,
ensureTransformWired,
isTransformOwned,
wireTransform
} from '../properties/transform-setup';
import { applyFolds, planFolds } from '../keyframes/fold';
import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types';
import { isBrowser, shouldReduceMotion } from '../../shared/browser';
import { formatValue, resolveProp } from '../properties/prop-utils';
Expand Down Expand Up @@ -60,23 +69,74 @@ export const animate = (
return noopController(element, defaults);
}

// Read before registering: an element with no transform channel in flight is
// one whose transform we can drive directly for the length of this call.
const canFold = needsTransform && !hasActiveTransforms(element);

if (needsTransform) {
ensureTransformWired(element);
registerTransformAnimation(element, transformBits);
}

const folds = canFold ? foldTransforms(element, groups) : [];
const animations = buildAnimations(element, groups, defaults);
if (folds.length > 0) {
registerFoldedTransforms(
element,
folds.map(({ groupIndex, varFrames, targets }) => ({
animation: animations[groupIndex]!,
varFrames,
targets
}))
);
}

return createController({
element,
animations: buildAnimations(element, groups, defaults),
animations,
defaults,
finalStyles,
restorations,
onTeardown: needsTransform
? () => deregisterTransformAnimation(element, transformBits)
? () => {
demoteFoldedTransforms(element);
deregisterTransformAnimation(element, transformBits);
}
: undefined
});
};

type PendingFold = Omit<FoldedTransform, 'animation'> & { groupIndex: number };

/**
* Collapse this call's transform-variable keyframes into direct
* `translate` / `scale` / `rotate` keyframes wherever possible, so Chromium can
* run them on the compositor instead of recalculating style every frame.
* Mutates `groups`, and returns what the tracker needs to undo the fold.
*/
const foldTransforms = (element: MotionElement, groups: KeyframeGroup[]): PendingFold[] => {
const computed = window.getComputedStyle(element);
const plans = planFolds(
groups,
(name) => computed.getPropertyValue(name).trim(),
// An unset inline value means the template never took (no CSS typed-OM
// support for these properties), leaving nothing to fold onto.
(target) => isTransformOwned(element, target) && !!element.style.getPropertyValue(target)
);
if (plans.length === 0) return [];
const originals = applyFolds(groups, plans);
return [...originals].map(([groupIndex, keyframes]) => {
const { offset } = groups[groupIndex]!;
return {
groupIndex,
varFrames: (offset ? { ...keyframes, offset } : keyframes) as PropertyIndexedKeyframes,
targets: plans
.filter((plan) => plan.groupIndex === groupIndex)
.map((plan) => [plan.target, element.style.getPropertyValue(plan.target)] as const)
};
});
};

/**
* Materialize each keyframe group into a WAAPI `Animation`, applying the shared
* effect options (fill / composite / iterations / direction / …) from `defaults`.
Expand Down
11 changes: 10 additions & 1 deletion src/lib/animate/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import { isBrowser } from '../../shared/browser';
import { demoteFoldedTransforms } from '../properties/properties';
import { playbackControls } from '../../shared/playback';
import type { AnimateDefaults, AnimationController, MotionElement } from '../types';
import type { CssWrite } from '../keyframes/keyframes';
Expand Down Expand Up @@ -188,7 +189,15 @@ export const createController = ({
},
cancel: teardown,
stop: () => {
if (isBrowser()) commitComputedStyles(element, finalStyles);
if (isBrowser()) {
// `commitComputedStyles` reads the animated custom properties. While
// folded, those hold their pre-animation values and the live position
// lives on `translate`/`scale` instead, so hand the animation back to
// the variable path first — it keeps its current time, so the values
// read back are the ones on screen.
demoteFoldedTransforms(element);
commitComputedStyles(element, finalStyles);
}
teardown();
},
...playbackControls(forEachAnim)
Expand Down
73 changes: 73 additions & 0 deletions src/lib/animate/core/fold-equivalence.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it } from 'vitest';
import { animate } from './animate';

const nodes: HTMLElement[] = [];
const mount = (css = ''): HTMLElement => {
const el = document.createElement('div');
el.style.cssText = `position:fixed;top:50px;left:50px;width:100px;height:80px;${css}`;
document.body.appendChild(el);
nodes.push(el);
return el;
};
afterEach(() => {
for (const n of nodes.splice(0)) n.remove();
});

const FLIP = { flipX: [120, 0], flipY: [40, 0], flipScaleX: [0.5, 1], flipScaleY: [0.25, 1] };
const rect = (el: Element) => {
const r = el.getBoundingClientRect();
return [r.left, r.top, r.width, r.height].map((n) => Math.round(n * 100) / 100);
};

describe('fold equivalence', () => {
it('renders the same frame as the variable path', () => {
for (const css of ['', 'transform-origin:0 0;', '--motion-x:15px;--motion-scale:1.5;']) {
const a = mount(css);
const b = mount(css);
const folded = animate(a, FLIP as never, { duration: 400, easing: (t: number) => t });
const vars = animate(b, FLIP as never, { duration: 400, easing: (t: number) => t });
// A second animation demotes b onto the variable path.
const nudge = animate(b, { rotate: [0, 0] }, { duration: 400 });
for (const c of [folded, vars]) {
c.pause();
c.seek(150);
}
expect(rect(a)).toEqual(rect(b));
folded.cancel();
vars.cancel();
nudge.cancel();
}
});

it('stop() freezes a folded animation where it is on screen', () => {
const el = mount();
const c = animate(el, FLIP as never, { duration: 400, easing: (t: number) => t });
c.pause();
c.seek(100);
const before = rect(el);
c.stop();
expect(rect(el)).toEqual(before);
expect(parseFloat(el.style.getPropertyValue('--flip-x'))).toBeCloseTo(90, 1);
});

it('lands on the end state and cleans up', async () => {
const el = mount();
const resting = rect(el);
const c = animate(el, { ...FLIP, width: [50, 100] } as never, { duration: 60 });
expect(c.animations).toHaveLength(2);
await c.finished;
expect(rect(el)).toEqual(resting);
expect(el.style.getPropertyValue('will-change')).toBe('');
expect(el.getAnimations()).toHaveLength(0);
});

it('inline writes to a motion var show up mid-fold', async () => {
const el = mount();
const c = animate(el, { flipX: [0, 0] }, { duration: 400 });
const before = rect(el);
el.style.setProperty('--motion-x', '25px');
await new Promise((r) => setTimeout(r, 0));
expect(rect(el)[0]).toBeCloseTo(before[0]! + 25, 1);
c.cancel();
});
});
104 changes: 104 additions & 0 deletions src/lib/animate/core/fold.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* The transform fold, end to end in a real browser. Runs in the `client`
* project, where `style.translate` and registered custom properties actually
* exist — the unit tests in `keyframes/fold.test.ts` cover the substitution
* itself, this covers whether `animate()` engages it and gives it back.
*
* Nothing here asserts that an animation is composited: Chromium exposes no
* API for that. What is assertable is the shape the compositor requires —
* real transform properties in the effect and no custom properties beside
* them — plus the promise that the fold is invisible to everything else.
*/

import { afterEach, describe, expect, it } from 'vitest';
import { animate } from './animate';
import { measureWithoutAncestorTransforms } from '../properties/properties';

let node: HTMLElement | null = null;

const mount = (): HTMLElement => {
const el = document.createElement('div');
el.style.cssText = 'position:fixed;top:50px;left:50px;width:100px;height:100px';
document.body.appendChild(el);
node = el;
return el;
};

afterEach(() => {
node?.remove();
node = null;
});

/** Keys WAAPI adds to every computed keyframe that are not animated properties. */
const KEYFRAME_METADATA = new Set(['offset', 'computedOffset', 'easing', 'composite']);

/** The property names an effect animates, as WAAPI reports them back. */
const animatedProps = (animation: Animation): string[] => [
...new Set(
(animation.effect as KeyframeEffect)
.getKeyframes()
.flatMap((frame: Keyframe) => Object.keys(frame))
.filter((key: string) => !KEYFRAME_METADATA.has(key))
)
];

describe('transform folding', () => {
it('drives real transform properties instead of custom properties', () => {
const el = mount();

const controller = animate(
el,
{
flipX: [120, 0],
flipY: [40, 0],
flipScaleX: [0.5, 1],
flipScaleY: [0.5, 1],
opacity: [0, 1]
},
{ duration: 400 }
);

const props = animatedProps(controller.animations[0]!);
expect(props).toContain('translate');
expect(props).toContain('scale');
expect(props.some((prop) => prop.startsWith('--'))).toBe(false);
controller.cancel();
});

it('hands the element back to the variables when a second animation starts', () => {
const el = mount();
const first = animate(el, { flipX: [120, 0] }, { duration: 400 });
expect(animatedProps(first.animations[0]!)).toContain('translate');

// A sibling animation owns `--motion-x`; the fold would mask it.
const second = animate(el, { x: [0, 30] }, { duration: 400 });

expect(animatedProps(first.animations[0]!)).toEqual(['--flip-x']);
expect(first.animations[0]!.playState).toBe('running');
first.cancel();
second.cancel();
});

it('still measures the resting box while folded', () => {
const el = mount();
const resting = el.getBoundingClientRect();

const controller = animate(el, { flipX: [200, 0] }, { duration: 400 });

// The element is visibly displaced, but FLIP must still read its slot.
expect(measureWithoutAncestorTransforms(el).left).toBeCloseTo(resting.left, 1);
controller.cancel();
});

it('leaves the transform alone when the caller set their own', () => {
const el = mount();
el.style.translate = '10px 10px';

const controller = animate(el, { flipX: [120, 0] }, { duration: 400 });

// Folding here would animate a value we do not own.
expect(animatedProps(controller.animations[0]!)).toEqual(['--flip-x']);
expect(el.style.translate).toBe('10px 10px');
controller.cancel();
});
});
Loading
Loading