From 668225be7724f8bc9865967682c1ce5d2c294039 Mon Sep 17 00:00:00 2001 From: professorpalmer Date: Mon, 31 Aug 2026 00:06:35 -0500 Subject: [PATCH 1/3] feat(motion): native spring integrator and GELATIN preset --- packages/native/js/host.ts | 16 +- packages/native/src/motion.rs | 339 ++++++++++++++++-- .../react/src/__tests__/motion-spring.test.ts | 28 ++ packages/react/src/components/index.ts | 109 +++++- packages/react/src/index.ts | 3 + packages/react/src/motion-spring.ts | 41 +++ packages/react/src/reconciler/renderer.ts | 18 +- 7 files changed, 521 insertions(+), 33 deletions(-) create mode 100644 packages/react/src/__tests__/motion-spring.test.ts create mode 100644 packages/react/src/motion-spring.ts diff --git a/packages/native/js/host.ts b/packages/native/js/host.ts index 04e6143c..ffa650eb 100644 --- a/packages/native/js/host.ts +++ b/packages/native/js/host.ts @@ -43,7 +43,8 @@ export type MotionEase = | "easeInOut" | [number, number, number, number] -export interface MotionTransition { +export interface MotionTweenTransition { + type?: "tween" /** Duration in seconds. */ duration?: number /** Delay in seconds. */ @@ -51,6 +52,19 @@ export interface MotionTransition { ease?: MotionEase } +/** Critically-underdamped integrator. Defaults are gelatinous, not snappy. */ +export interface MotionSpringTransition { + type: "spring" + stiffness?: number + damping?: number + mass?: number + velocity?: number + /** Delay in seconds. */ + delay?: number +} + +export type MotionTransition = MotionTweenTransition | MotionSpringTransition + export interface MotionProps { initial?: MotionStyle | false animate: MotionStyle diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index a528d47c..13047263 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -1,4 +1,5 @@ //! Native motion tracks resolved during GPUI rendering, outside React. +//! Tween (duration/ease) plus spring (stiffness/damping/mass/velocity) integrators. use std::time::Duration; @@ -51,6 +52,33 @@ impl MotionStyle { } } + fn channels(self) -> [(&'static str, Option); 8] { + [ + ("width", self.width), + ("height", self.height), + ("opacity", self.opacity), + ("top", self.top), + ("right", self.right), + ("bottom", self.bottom), + ("left", self.left), + ("borderRadius", self.border_radius), + ] + } + + fn set(&mut self, name: &str, value: f64) { + match name { + "width" => self.width = Some(value), + "height" => self.height = Some(value), + "opacity" => self.opacity = Some(value), + "top" => self.top = Some(value), + "right" => self.right = Some(value), + "bottom" => self.bottom = Some(value), + "left" => self.left = Some(value), + "borderRadius" => self.border_radius = Some(value), + _ => {} + } + } + pub(crate) fn apply_to(self, style: &mut StyleDesc) { if let Some(value) = self.width { style.width = Some(DimensionValue::Pixels(value)); @@ -95,7 +123,7 @@ enum MotionEase { #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -struct MotionTransition { +struct TweenTransition { #[serde(default = "default_duration")] duration: f64, #[serde(default)] @@ -104,13 +132,37 @@ struct MotionTransition { ease: MotionEase, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct SpringTransition { + #[serde(rename = "type")] + kind: String, + #[serde(default = "default_stiffness")] + stiffness: f64, + #[serde(default = "default_damping")] + damping: f64, + #[serde(default = "default_mass")] + mass: f64, + #[serde(default)] + velocity: f64, + #[serde(default)] + delay: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum MotionTransition { + Spring(SpringTransition), + Tween(TweenTransition), +} + impl Default for MotionTransition { fn default() -> Self { - Self { + Self::Tween(TweenTransition { duration: default_duration(), delay: 0.0, ease: default_ease(), - } + }) } } @@ -122,6 +174,18 @@ fn default_ease() -> MotionEase { MotionEase::Name("easeOut".to_string()) } +fn default_stiffness() -> f64 { + 36.0 +} + +fn default_damping() -> f64 { + 8.0 +} + +fn default_mass() -> f64 { + 1.2 +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] struct MotionDescription { @@ -143,17 +207,38 @@ pub(crate) struct MotionFrame { pub generation: u64, } +#[derive(Clone, Copy, Debug, Default)] +struct SpringTrack { + pos: f64, + vel: f64, +} + pub(crate) struct MotionState { source: serde_json::Value, from: MotionStyle, target: MotionStyle, + /// Last integrated spring style; tweens sample from `from`/`target` instead. + current: MotionStyle, transition: MotionTransition, started: Instant, + last: Instant, + springs: [SpringTrack; 8], valid: bool, needs_settle: bool, generation: u64, } +const CHANNELS: [&str; 8] = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +]; + impl MotionState { pub(crate) fn new(source: &serde_json::Value, now: Instant) -> Result { let description = parse_description(source)?; @@ -164,12 +249,16 @@ impl MotionState { }; let target = description.animate.with_fallback(from); + let kick = spring_kick(&description.transition); Ok(Self { source: source.clone(), from, target, + current: from, transition: description.transition, started: now, + last: now, + springs: seed_springs(from, target, kick), valid: true, needs_settle: description.is_exit || from != target, generation: description.generation, @@ -181,8 +270,11 @@ impl MotionState { source: source.clone(), from: MotionStyle::default(), target: MotionStyle::default(), + current: MotionStyle::default(), transition: MotionTransition::default(), started: now, + last: now, + springs: [SpringTrack::default(); 8], valid: false, needs_settle: source_is_exit(source), generation: source_generation(source), @@ -216,8 +308,25 @@ impl MotionState { } }; self.target = description.animate.with_fallback(self.from); + self.current = self.from; + let kick = spring_kick(&description.transition); + if matches!(description.transition, MotionTransition::Spring(_)) { + // Keep velocity; retarget in place so overshoot carries. + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(self.from, name) + .or_else(|| channel(self.target, name)) + .unwrap_or(0.0); + self.springs[index].pos = pos; + if self.springs[index].vel.abs() < f64::EPSILON { + self.springs[index].vel = kick; + } + } + } else { + self.springs = seed_springs(self.from, self.target, kick); + } self.transition = description.transition; self.started = now; + self.last = now; self.source = source.clone(); self.valid = true; self.generation = description.generation; @@ -232,8 +341,18 @@ impl MotionState { } fn sample(&self, now: Instant) -> (MotionStyle, bool) { - let delay = seconds(self.transition.delay); - let duration = seconds(self.transition.duration); + match &self.transition { + // Springs are integrated in `frame`; the visible value is the last step. + MotionTransition::Spring(_) => { + (self.current, !settled(&self.springs, self.target)) + } + MotionTransition::Tween(tween) => self.sample_tween(now, tween), + } + } + + fn sample_tween(&self, now: Instant, tween: &TweenTransition) -> (MotionStyle, bool) { + let delay = seconds(tween.delay); + let duration = seconds(tween.duration); let elapsed = now.saturating_duration_since(self.started); let raw = if duration.is_zero() { if elapsed < delay { @@ -247,15 +366,18 @@ impl MotionState { elapsed.saturating_sub(delay).as_secs_f64() / duration.as_secs_f64() }; let active = self.from != self.target && raw < 1.0; - let progress = ease(raw.clamp(0.0, 1.0), &self.transition.ease); + let progress = ease(raw.clamp(0.0, 1.0), &tween.ease); (self.from.interpolate(self.target, progress), active) } pub(crate) fn frame(&mut self, now: Instant) -> MotionFrame { - let (_, active) = if self.valid { - self.sample(now) + let active = if !self.valid { + false + } else if let MotionTransition::Spring(spring) = &self.transition { + let spring = spring.clone(); + self.step_springs(now, &spring) } else { - (MotionStyle::default(), false) + self.sample(now).1 }; if active { self.needs_settle = true; @@ -270,6 +392,110 @@ impl MotionState { generation: self.generation, } } + + /// Advance every targeted spring channel to `now` and return whether any is still moving. + fn step_springs(&mut self, now: Instant, spring: &SpringTransition) -> bool { + let delay = seconds(spring.delay); + if now.saturating_duration_since(self.started) < delay { + self.last = now; + return self.from != self.target; + } + let dt = now.saturating_duration_since(self.last).as_secs_f64(); + self.last = now; + if dt <= 0.0 { + return !settled(&self.springs, self.target); + } + let dt = dt.min(0.032); + let mut style = self.current; + let mut active = false; + for (index, name) in CHANNELS.iter().enumerate() { + let Some(target) = channel(self.target, name) else { + continue; + }; + let rest = if *name == "opacity" { 0.002 } else { 0.05 }; + let next = step_spring( + self.springs[index], + target, + dt, + spring.stiffness, + spring.damping, + spring.mass, + rest, + ); + self.springs[index] = next; + style.set(name, next.pos); + if (next.pos - target).abs() > rest || next.vel.abs() > rest { + active = true; + } + } + self.current = style; + active + } +} + +fn channel(style: MotionStyle, name: &str) -> Option { + match name { + "width" => style.width, + "height" => style.height, + "opacity" => style.opacity, + "top" => style.top, + "right" => style.right, + "bottom" => style.bottom, + "left" => style.left, + "borderRadius" => style.border_radius, + _ => None, + } +} + +fn spring_kick(transition: &MotionTransition) -> f64 { + match transition { + MotionTransition::Spring(spring) => spring.velocity, + MotionTransition::Tween(_) => 0.0, + } +} + +fn seed_springs(from: MotionStyle, target: MotionStyle, kick: f64) -> [SpringTrack; 8] { + let mut tracks = [SpringTrack::default(); 8]; + for (index, name) in CHANNELS.iter().enumerate() { + let pos = channel(from, name) + .or_else(|| channel(target, name)) + .unwrap_or(0.0); + tracks[index] = SpringTrack { pos, vel: kick }; + } + tracks +} + +fn settled(tracks: &[SpringTrack; 8], target: MotionStyle) -> bool { + for (index, name) in CHANNELS.iter().enumerate() { + let Some(to) = channel(target, name) else { + continue; + }; + if (tracks[index].pos - to).abs() > 0.05 || tracks[index].vel.abs() > 0.05 { + return false; + } + } + true +} + +fn step_spring( + track: SpringTrack, + target: f64, + dt: f64, + stiffness: f64, + damping: f64, + mass: f64, + rest: f64, +) -> SpringTrack { + let mass = mass.max(0.001); + let x = track.pos - target; + let accel = (-stiffness * x - damping * track.vel) / mass; + let vel = track.vel + accel * dt; + let pos = track.pos + vel * dt; + if (pos - target).abs() < rest && vel.abs() < rest { + SpringTrack { pos: target, vel: 0.0 } + } else { + SpringTrack { pos, vel } + } } fn source_generation(source: &serde_json::Value) -> u64 { @@ -287,8 +513,21 @@ fn source_is_exit(source: &serde_json::Value) -> bool { } fn parse_description(source: &serde_json::Value) -> Result { - let description: MotionDescription = + let mut description: MotionDescription = serde_json::from_value(source.clone()).map_err(|error| error.to_string())?; + // `type` makes any transition match the untagged Spring variant first; an + // explicit `type: "tween"` is a tween. + if let MotionTransition::Spring(spring) = &description.transition { + if spring.kind == "tween" { + let tween = source + .get("transition") + .cloned() + .unwrap_or_default(); + description.transition = MotionTransition::Tween( + serde_json::from_value(tween).map_err(|error| error.to_string())?, + ); + } + } if matches!(description.initial, Some(MotionInitial::Disabled(true))) { return Err("motion initial only accepts false or a style object".to_string()); @@ -297,23 +536,30 @@ fn parse_description(source: &serde_json::Value) -> Result { + validate_seconds(tween.duration, "duration")?; + validate_seconds(tween.delay, "delay")?; + validate_ease(&tween.ease)?; + } + MotionTransition::Spring(spring) => { + if spring.kind != "spring" { + return Err(format!("unknown motion type: {}", spring.kind)); + } + validate_positive(spring.stiffness, "stiffness")?; + validate_positive(spring.damping, "damping")?; + validate_positive(spring.mass, "mass")?; + validate_seconds(spring.delay, "delay")?; + if !spring.velocity.is_finite() { + return Err("motion velocity must be finite".to_string()); + } + } + } Ok(description) } fn validate_style(style: &MotionStyle) -> Result<(), String> { - for (name, value) in [ - ("width", style.width), - ("height", style.height), - ("opacity", style.opacity), - ("top", style.top), - ("right", style.right), - ("bottom", style.bottom), - ("left", style.left), - ("borderRadius", style.border_radius), - ] { + for (name, value) in style.channels() { if value.is_some_and(|value| !value.is_finite() || value.abs() > f32::MAX as f64) { return Err(format!("motion {name} must fit a finite 32-bit float")); } @@ -342,6 +588,15 @@ fn validate_seconds(value: f64, name: &str) -> Result<(), String> { Ok(()) } +fn validate_positive(value: f64, name: &str) -> Result<(), String> { + if !value.is_finite() || value <= 0.0 { + return Err(format!( + "motion {name} must be a finite number greater than 0" + )); + } + Ok(()) +} + fn validate_ease(ease: &MotionEase) -> Result<(), String> { match ease { MotionEase::Name(name) @@ -572,4 +827,42 @@ mod tests { assert_eq!(style.width, Some(100.0)); assert_eq!(style.opacity, Some(0.5)); } + + #[test] + fn spring_overshoots_then_settles() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "width": 0.0 }, + "animate": { "width": 100.0 }, + "transition": { "type": "spring", "stiffness": 40.0, "damping": 6.0, "mass": 1.0 } + }); + let mut state = MotionState::new(&description, started).unwrap(); + let mut max_width = 0.0_f64; + let mut saw_settle = false; + let mut now = started; + for _ in 0..120 { + now += Duration::from_millis(8); + let frame = state.frame(now); + saw_settle |= frame.just_settled; + max_width = max_width.max(state.visible_style(now).unwrap().width.unwrap_or(0.0)); + } + assert!(max_width > 100.0, "gelatinous spring must overshoot, got {max_width}"); + now += Duration::from_millis(8); + let frame = state.frame(now); + saw_settle |= frame.just_settled; + let width = state.visible_style(now).unwrap().width.unwrap_or(0.0); + assert!((width - 100.0).abs() < 1.0, "spring must settle near target, got {width}"); + assert!(!frame.active); + assert!(saw_settle, "spring must report just_settled once at rest"); + } + + #[test] + fn spring_rejects_non_positive_parameters() { + let now = Instant::now(); + let description = serde_json::json!({ + "animate": { "width": 1.0 }, + "transition": { "type": "spring", "stiffness": 0.0 } + }); + assert!(MotionState::new(&description, now).is_err()); + } } diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts new file mode 100644 index 00000000..70497f42 --- /dev/null +++ b/packages/react/src/__tests__/motion-spring.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js" + +describe("stepSpring", () => { + it("snaps to rest when inside the rest window", () => { + const next = stepSpring({ pos: 100.02, vel: 0.01 }, 100, 1 / 60, 28, 8, 1.25) + expect(next).toEqual({ pos: 100, vel: 0 }) + }) + + it("overshoots with GELATIN then settles on the target", () => { + let track: SpringTrack = { pos: 0, vel: 0 } + let max = 0 + for (let i = 0; i < 240; i++) { + track = stepSpring( + track, + 100, + 1 / 60, + GELATIN.stiffness, + GELATIN.damping, + GELATIN.mass + ) + max = Math.max(max, track.pos) + } + expect(max).toBeGreaterThan(100) + expect(track.pos).toBe(100) + expect(track.vel).toBe(0) + }) +}) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index b6aae3cf..a07a3511 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,8 +1,16 @@ // GPUIX component definitions and native motion wrappers. -import { createElement, forwardRef, useContext, useEffect, useMemo } from "react" +import { createElement, forwardRef, useContext, useEffect, useMemo, useRef, useState } from "react" import type { ReactElement, ReactNode } from "react" -import type { MotionProps, Props, PublicInstance, StyleDesc } from "../types/host.js" +import type { + MotionProps, + MotionSpringTransition, + MotionStyle, + Props, + PublicInstance, + StyleDesc, +} from "../types/host.js" +import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js" import { PresenceContext, usePresence } from "./animate-presence.js" let nextMotionGeneration = 0 @@ -63,29 +71,113 @@ export interface MotionDivProps extends MotionProps { autoFocus?: boolean } +const SPRING_KEYS = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +] as const + +type SpringKey = (typeof SPRING_KEYS)[number] + +function isSpringTransition( + transition: MotionProps["transition"] +): transition is MotionSpringTransition { + return transition != null && transition.type === "spring" +} + +function readStyle(style: MotionStyle | false | undefined, key: SpringKey): number | undefined { + if (style == null || style === false) return undefined + return style[key] +} + const MotionDiv = forwardRef(function MotionDiv( - { initial, animate, exit, transition, onMotionComplete, ...props }, + { initial, animate, exit, transition, onMotionComplete, style, ...props }, ref ): ReactElement { const presence = useContext(PresenceContext) const [isPresent, safeToRemove] = usePresence() const resolvedInitial = presence?.initial === false ? false : initial const resolvedAnimate = !isPresent && exit ? exit : animate + const spring = isSpringTransition(transition) useEffect(() => { - if (!isPresent && !exit) safeToRemove?.() - }, [exit, isPresent, safeToRemove]) + // Spring leases are JS-driven and report no native motionComplete yet. + if (!isPresent && (!exit || spring)) safeToRemove?.() + }, [exit, isPresent, safeToRemove, spring]) + + const animateRef = useRef(resolvedAnimate) + animateRef.current = resolvedAnimate + const [current, setCurrent] = useState(() => { + const seed: MotionStyle = {} + for (const key of SPRING_KEYS) { + const value = readStyle(resolvedInitial, key) ?? resolvedAnimate[key] + if (value != null) seed[key] = value + } + return seed + }) + const tracks = useRef>>({}) + const transitionRef = useRef(transition) + transitionRef.current = transition + + useEffect(() => { + if (!spring) return + return onFrame((dt) => { + const spec = transitionRef.current + if (!isSpringTransition(spec)) return + const stiffness = spec.stiffness ?? GELATIN.stiffness + const damping = spec.damping ?? GELATIN.damping + const mass = spec.mass ?? GELATIN.mass + const kick = spec.velocity ?? 0 + const target = animateRef.current + let changed = false + const next: MotionStyle = {} + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const rest = key === "opacity" ? 0.002 : 0.05 + let track = tracks.current[key] + if (!track) track = { pos: to, vel: kick } + const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest) + if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true + tracks.current[key] = stepped + next[key] = stepped.pos + } + if (changed) setCurrent(next) + }) + }, [spring]) const motionKey = JSON.stringify([ isPresent, motionStyleKey(resolvedInitial), motionStyleKey(resolvedAnimate), - transition?.duration, - transition?.delay, - transition?.ease, + transition ?? null, ]) const generation = useMemo(() => ++nextMotionGeneration, [motionKey]) + if (spring) { + const hostProps: Props = { + ...props, + ref, + style: { + ...(style ?? {}), + ...(current.width != null ? { width: current.width } : {}), + ...(current.height != null ? { height: current.height } : {}), + ...(current.opacity != null ? { opacity: current.opacity } : {}), + ...(current.top != null ? { top: current.top } : {}), + ...(current.right != null ? { right: current.right } : {}), + ...(current.bottom != null ? { bottom: current.bottom } : {}), + ...(current.left != null ? { left: current.left } : {}), + ...(current.borderRadius != null ? { borderRadius: current.borderRadius } : {}), + }, + } + return createElement("div", hostProps) + } + const motionDescription = { generation, isExit: !isPresent && exit !== undefined, @@ -96,6 +188,7 @@ const MotionDiv = forwardRef(function MotionDiv( const hostProps: Props = { ...props, ref, + style, motion: motionDescription, } if (!isPresent || onMotionComplete) { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9b0111a7..3d5f2674 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -105,6 +105,7 @@ export { usePresence, } from "./components/index.js" export type { AnimatePresenceProps } from "./components/index.js" +export { onFrame, stepSpring, GELATIN } from "./motion-spring.js" export type { Root, FrameLoop, RenderOptions } from "./reconciler/renderer.js" export type { WindowInsets, @@ -141,6 +142,8 @@ export type { MotionProps, MotionStyle, MotionTransition, + MotionSpringTransition, + MotionTweenTransition, MutationHost, NativeRenderer, NativeWindowInsets, diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts new file mode 100644 index 00000000..0cb10c16 --- /dev/null +++ b/packages/react/src/motion-spring.ts @@ -0,0 +1,41 @@ +/** Semi-implicit Euler spring. Runs on the GPUIX frame loop, not CSS tweens. */ + +export type SpringTrack = { pos: number; vel: number } + +export function stepSpring( + track: SpringTrack, + target: number, + dt: number, + stiffness: number, + damping: number, + mass: number, + rest = 0.05 +): SpringTrack { + const clamped = Math.min(Math.max(dt, 0), 0.032) + const m = Math.max(mass, 0.001) + const x = track.pos - target + const accel = (-stiffness * x - damping * track.vel) / m + const vel = track.vel + accel * clamped + const pos = track.pos + vel * clamped + if (Math.abs(pos - target) < rest && Math.abs(vel) < rest) { + return { pos: target, vel: 0 } + } + return { pos, vel } +} + +export type FrameListener = (dt: number, now: number) => void + +const listeners = new Set() + +export function onFrame(listener: FrameListener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function pumpFrames(dt: number, now: number): void { + for (const listener of listeners) listener(dt, now) +} + +export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 } diff --git a/packages/react/src/reconciler/renderer.ts b/packages/react/src/reconciler/renderer.ts index 95021380..f95ad36d 100644 --- a/packages/react/src/reconciler/renderer.ts +++ b/packages/react/src/reconciler/renderer.ts @@ -15,6 +15,7 @@ import type { WindowKeyEventHandlers, } from "../types/host.js" import type { LiveAutomationRenderer } from "@gpuix/native/automation" +import { pumpFrames } from "../motion-spring.js" export { createRoot, flushSync, reconciler } from "./reconciler.js" export type { Root } from "./reconciler.js" @@ -108,7 +109,22 @@ export function startFrameLoop( renderer: Pick, options: { frameMs?: number; onTerminated?: () => void } = {} ): FrameLoop { - return startNativeFrameLoop(renderer, { + // Spring motion.div leases step on the same loop, just before each tick. + let lastFrame = performance.now() + const pumped: Pick = { + requiresTick: () => renderer.requiresTick(), + tick: () => { + const started = performance.now() + try { + pumpFrames(Math.min((started - lastFrame) / 1000, 0.032), started) + } catch (error) { + scheduleRuntimeError(thrownToError(error)) + } + lastFrame = started + return renderer.tick() + }, + } + return startNativeFrameLoop(pumped, { ...options, onError: (error) => scheduleRuntimeError(thrownToError(error)), }) From 6ace62974979eee7a7a9c64efc12332cd848460e Mon Sep 17 00:00:00 2001 From: Cary Palmer Date: Wed, 9 Sep 2026 21:24:23 -0500 Subject: [PATCH 2/3] fix(motion): park spring MotionDiv leases at rest Unsubscribe onFrame when every spring channel settles, skip setCurrent for unchanged integer px/opacity, and hard-settle leftover GELATIN crawl so GPUI is not rebuilt every frame. Tween motion.div is unchanged. Co-authored-by: Cary Palmer --- .changeset/spring-lease-parking.md | 5 + .../react/src/__tests__/motion-spring.test.ts | 151 +++++++++++++++- packages/react/src/components/index.ts | 93 ++++++---- packages/react/src/motion-spring.ts | 167 +++++++++++++++++- 4 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 .changeset/spring-lease-parking.md diff --git a/.changeset/spring-lease-parking.md b/.changeset/spring-lease-parking.md new file mode 100644 index 00000000..449701e5 --- /dev/null +++ b/.changeset/spring-lease-parking.md @@ -0,0 +1,5 @@ +--- +'@gpuix/react': patch +--- + +Park spring `motion.div` frame leases at rest so a settled spring stops calling `setCurrent` and no longer rebuilds GPUI every frame. A retarget re-subscribes `onFrame`. Soft GELATIN crawl hard-settles between 280ms and 420ms. Tween `motion.div` is unchanged. diff --git a/packages/react/src/__tests__/motion-spring.test.ts b/packages/react/src/__tests__/motion-spring.test.ts index 70497f42..5ae4aa99 100644 --- a/packages/react/src/__tests__/motion-spring.test.ts +++ b/packages/react/src/__tests__/motion-spring.test.ts @@ -1,5 +1,25 @@ -import { describe, expect, it } from "vitest" -import { GELATIN, stepSpring, type SpringTrack } from "../motion-spring.js" +import { afterEach, describe, expect, it } from "vitest" +import { + allSpringChannelsRest, + GELATIN, + isSpringRest, + pumpFrames, + quantizeSpringValue, + resetSpringClockForTests, + SETTLE_HARD_MS, + shouldSnapSpring, + snapSpring, + springClockBusy, + springShouldPublish, + stepSpring, + stepSpringLease, + subscribeSpringTick, + type SpringTrack, +} from "../motion-spring.js" + +afterEach(() => { + resetSpringClockForTests() +}) describe("stepSpring", () => { it("snaps to rest when inside the rest window", () => { @@ -26,3 +46,130 @@ describe("stepSpring", () => { expect(track.vel).toBe(0) }) }) + +describe("spring lease parking", () => { + it("skips no-op and sub-pixel publishes", () => { + expect(springShouldPublish(12, 12)).toBe(false) + expect(springShouldPublish(12, 12.4)).toBe(false) + expect(springShouldPublish(12, 12.6)).toBe(true) + expect(quantizeSpringValue(12.4)).toBe(12) + expect(quantizeSpringValue(12.6)).toBe(13) + expect(springShouldPublish(0.5, 0.5005, "opacity")).toBe(false) + expect(springShouldPublish(0.5, 0.51, "opacity")).toBe(true) + }) + + it("snaps a crawl, not an in-flight travel", () => { + expect(shouldSnapSpring({ pos: 10.3, vel: 0.02 }, 10)).toBe(true) + expect(shouldSnapSpring({ pos: 18, vel: 2.4 }, 10)).toBe(false) + expect(shouldSnapSpring({ pos: 68, vel: 40 }, 10, SETTLE_HARD_MS)).toBe(false) + expect(shouldSnapSpring({ pos: 12.5, vel: 1.2 }, 10, SETTLE_HARD_MS)).toBe(true) + expect(snapSpring(10)).toEqual({ pos: 10, vel: 0 }) + expect(isSpringRest(snapSpring(10), 10)).toBe(true) + }) + + it("parks onFrame when every channel is at rest", () => { + const tracks = { width: { pos: 10.2, vel: 0.08 } } + let painted = { width: 10 } + expect(springClockBusy()).toBe(false) + subscribeSpringTick((dt) => { + const result = stepSpringLease({ + tracks, + target: { width: 10 }, + painted, + dt, + elapsedMs: SETTLE_HARD_MS, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + painted = result.painted + return result.moving + }) + expect(springClockBusy()).toBe(true) + pumpFrames(1 / 60, 0) + expect(isSpringRest(tracks.width, 10)).toBe(true) + expect(allSpringChannelsRest(tracks, { width: 10 })).toBe(true) + expect(springClockBusy()).toBe(false) + }) + + it("wakes on animate retarget", () => { + const tracks = { width: snapSpring(10) } + let painted = { width: 10 } + let target = { width: 10 } + const arm = () => { + if (allSpringChannelsRest(tracks, target)) return + return subscribeSpringTick((dt) => { + const result = stepSpringLease({ + tracks, + target, + painted, + dt, + elapsedMs: 0, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + painted = result.painted + return result.moving + }) + } + + arm() + expect(springClockBusy()).toBe(false) + + target = { width: 80 } + arm() + expect(springClockBusy()).toBe(true) + pumpFrames(1 / 60, 16) + expect(tracks.width.pos).not.toBe(80) + expect(tracks.width.vel).not.toBe(0) + }) + + it("does not publish when the visual is already settled", () => { + const tracks = { width: { pos: 12.2, vel: 0.01 }, opacity: { pos: 1.0004, vel: 0 } } + const result = stepSpringLease({ + tracks, + target: { width: 12, opacity: 1 }, + painted: { width: 12, opacity: 1 }, + dt: 1 / 60, + elapsedMs: 0, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + expect(result.publish).toBe(false) + expect(result.moving).toBe(false) + expect(result.painted).toEqual({ width: 12, opacity: 1 }) + + let publishes = 0 + subscribeSpringTick((dt) => { + const next = stepSpringLease({ + tracks, + target: { width: 12, opacity: 1 }, + painted: result.painted, + dt, + elapsedMs: SETTLE_HARD_MS, + stiffness: GELATIN.stiffness, + damping: GELATIN.damping, + mass: GELATIN.mass, + }) + if (next.publish) publishes += 1 + return next.moving + }) + pumpFrames(1 / 60, 32) + pumpFrames(1 / 60, 48) + pumpFrames(1 / 60, 64) + expect(publishes).toBe(0) + expect(springClockBusy()).toBe(false) + }) + + it("hard-settles a leftover GELATIN crawl inside the 280–420ms budget", () => { + let track = { pos: 0, vel: 0 } + for (let i = 0; i < 55; i += 1) { + const elapsed = (i + 1) * (1000 / 125) + track = stepSpring(track, 8, 8 / 1000, GELATIN.stiffness, GELATIN.damping, GELATIN.mass) + if (shouldSnapSpring(track, 8, elapsed)) track = snapSpring(8) + } + expect(isSpringRest(track, 8)).toBe(true) + }) +}) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index a07a3511..d0de390c 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,7 +10,17 @@ import type { PublicInstance, StyleDesc, } from "../types/host.js" -import { GELATIN, onFrame, stepSpring, type SpringTrack } from "../motion-spring.js" +import { + allSpringChannelsRest, + animateSignature, + GELATIN, + seedSpringTrack, + SPRING_KEYS, + stepSpringLease, + subscribeSpringTick, + type SpringKey, + type SpringTrack, +} from "../motion-spring.js" import { PresenceContext, usePresence } from "./animate-presence.js" let nextMotionGeneration = 0 @@ -71,19 +81,6 @@ export interface MotionDivProps extends MotionProps { autoFocus?: boolean } -const SPRING_KEYS = [ - "width", - "height", - "opacity", - "top", - "right", - "bottom", - "left", - "borderRadius", -] as const - -type SpringKey = (typeof SPRING_KEYS)[number] - function isSpringTransition( transition: MotionProps["transition"] ): transition is MotionSpringTransition { @@ -106,9 +103,8 @@ const MotionDiv = forwardRef(function MotionDiv( const spring = isSpringTransition(transition) useEffect(() => { - // Spring leases are JS-driven and report no native motionComplete yet. - if (!isPresent && (!exit || spring)) safeToRemove?.() - }, [exit, isPresent, safeToRemove, spring]) + if (!isPresent && !exit) safeToRemove?.() + }, [exit, isPresent, safeToRemove]) const animateRef = useRef(resolvedAnimate) animateRef.current = resolvedAnimate @@ -121,35 +117,54 @@ const MotionDiv = forwardRef(function MotionDiv( return seed }) const tracks = useRef>>({}) + const paintedRef = useRef(current) const transitionRef = useRef(transition) transitionRef.current = transition + const exitingRef = useRef<(() => void) | null>(null) + // Springs are JS-driven, so an exit completes when the lease parks at rest + // rather than on a native motionComplete event. + exitingRef.current = spring && !isPresent && exit ? (safeToRemove ?? null) : null + const targetSignature = spring ? `${isPresent}|${animateSignature(resolvedAnimate)}` : "" useEffect(() => { if (!spring) return - return onFrame((dt) => { - const spec = transitionRef.current - if (!isSpringTransition(spec)) return - const stiffness = spec.stiffness ?? GELATIN.stiffness - const damping = spec.damping ?? GELATIN.damping - const mass = spec.mass ?? GELATIN.mass - const kick = spec.velocity ?? 0 - const target = animateRef.current - let changed = false - const next: MotionStyle = {} - for (const key of SPRING_KEYS) { - const to = target[key] - if (to == null) continue - const rest = key === "opacity" ? 0.002 : 0.05 - let track = tracks.current[key] - if (!track) track = { pos: to, vel: kick } - const stepped = stepSpring(track, to, dt, stiffness, damping, mass, rest) - if (stepped.pos !== track.pos || stepped.vel !== track.vel) changed = true - tracks.current[key] = stepped - next[key] = stepped.pos + const spec = transitionRef.current + const kick = isSpringTransition(spec) ? (spec.velocity ?? 0) : 0 + const target = animateRef.current + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + seedSpringTrack(tracks.current, key, paintedRef.current[key], to, kick) + } + if (allSpringChannelsRest(tracks.current, target)) { + exitingRef.current?.() + return + } + + let elapsedMs = 0 + return subscribeSpringTick((dt) => { + const live = transitionRef.current + if (!isSpringTransition(live)) return false + elapsedMs += dt * 1000 + const result = stepSpringLease({ + tracks: tracks.current, + target: animateRef.current, + painted: paintedRef.current, + dt, + elapsedMs, + stiffness: live.stiffness ?? GELATIN.stiffness, + damping: live.damping ?? GELATIN.damping, + mass: live.mass ?? GELATIN.mass, + kick: live.velocity ?? 0, + }) + if (result.publish) { + paintedRef.current = result.painted + setCurrent(result.painted) } - if (changed) setCurrent(next) + if (!result.moving) exitingRef.current?.() + return result.moving }) - }, [spring]) + }, [spring, targetSignature]) const motionKey = JSON.stringify([ isPresent, diff --git a/packages/react/src/motion-spring.ts b/packages/react/src/motion-spring.ts index 0cb10c16..164f155a 100644 --- a/packages/react/src/motion-spring.ts +++ b/packages/react/src/motion-spring.ts @@ -2,6 +2,36 @@ export type SpringTrack = { pos: number; vel: number } +export type SpringChannelKind = "px" | "opacity" + +export const SPRING_KEYS = [ + "width", + "height", + "opacity", + "top", + "right", + "bottom", + "left", + "borderRadius", +] as const + +export type SpringKey = (typeof SPRING_KEYS)[number] + +/** Soft GELATIN crawls; the budget forces rest so `onFrame` can park. */ +export const SETTLE_BUDGET_MS = 280 +export const SETTLE_HARD_MS = 420 + +const SNAP_POS_PX = 1.05 +const SNAP_VEL_PX = 0.35 +const CRAWL_POS_PX = 2.25 +/** Hard settle only kills leftover crawl, not an in-flight travel. */ +const HARD_CRAWL_POS_PX = 8 +const SNAP_POS_OPACITY = 0.002 +const SNAP_VEL_OPACITY = 0.02 +const CRAWL_POS_OPACITY = 0.02 +const HARD_CRAWL_POS_OPACITY = 0.08 +const OPACITY_PUBLISH_EPS = 0.002 + export function stepSpring( track: SpringTrack, target: number, @@ -23,6 +53,122 @@ export function stepSpring( return { pos, vel } } +export function snapSpring(target: number): SpringTrack { + return { pos: target, vel: 0 } +} + +export function isSpringRest(track: SpringTrack, target: number): boolean { + return track.vel === 0 && track.pos === target +} + +export function springChannelKind(key: SpringKey): SpringChannelKind { + return key === "opacity" ? "opacity" : "px" +} + +export function shouldSnapSpring( + track: SpringTrack, + target: number, + elapsedMs = 0, + kind: SpringChannelKind = "px" +): boolean { + const dist = Math.abs(track.pos - target) + const speed = Math.abs(track.vel) + if (kind === "opacity") { + if (speed < SNAP_VEL_OPACITY && dist < SNAP_POS_OPACITY) return true + if (elapsedMs >= SETTLE_BUDGET_MS && dist < CRAWL_POS_OPACITY) return true + return elapsedMs >= SETTLE_HARD_MS && dist < HARD_CRAWL_POS_OPACITY + } + if (speed < SNAP_VEL_PX && dist < SNAP_POS_PX) return true + if (elapsedMs >= SETTLE_BUDGET_MS && dist < CRAWL_POS_PX) return true + return elapsedMs >= SETTLE_HARD_MS && dist < HARD_CRAWL_POS_PX +} + +export function springShouldPublish( + previous: number, + next: number, + kind: SpringChannelKind = "px" +): boolean { + if (kind === "opacity") return Math.abs(next - previous) >= OPACITY_PUBLISH_EPS + return Math.round(next) !== Math.round(previous) +} + +export function quantizeSpringValue(value: number, kind: SpringChannelKind = "px"): number { + if (kind === "opacity") return value + return Math.round(value) +} + +export function animateSignature(style: Partial>): string { + return SPRING_KEYS.map((key) => `${key}:${style[key] ?? ""}`).join("|") +} + +export function seedSpringTrack( + tracks: Partial>, + key: SpringKey, + from: number | undefined, + to: number, + kick = 0 +): SpringTrack { + const existing = tracks[key] + if (existing) return existing + const track = { pos: from ?? to, vel: kick } + tracks[key] = track + return track +} + +export function allSpringChannelsRest( + tracks: Partial>, + target: Partial> +): boolean { + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const track = tracks[key] + if (!track || !isSpringRest(track, to)) return false + } + return true +} + +export function stepSpringLease(opts: { + tracks: Partial> + target: Partial> + painted: Partial> + dt: number + elapsedMs: number + stiffness: number + damping: number + mass: number + kick?: number +}): { + painted: Partial> + moving: boolean + publish: boolean +} { + const { tracks, target, dt, elapsedMs, stiffness, damping, mass, kick = 0 } = opts + let moving = false + let publish = false + const painted = { ...opts.painted } + for (const key of SPRING_KEYS) { + const to = target[key] + if (to == null) continue + const kind = springChannelKind(key) + const rest = kind === "opacity" ? SNAP_POS_OPACITY : 0.05 + let track = seedSpringTrack(tracks, key, painted[key], to, kick) + track = stepSpring(track, to, dt, stiffness, damping, mass, rest) + if (shouldSnapSpring(track, to, elapsedMs, kind)) track = snapSpring(to) + tracks[key] = track + if (!isSpringRest(track, to)) moving = true + const visual = isSpringRest(track, to) ? to : quantizeSpringValue(track.pos, kind) + const previous = painted[key] + if (previous == null || springShouldPublish(previous, visual, kind)) { + if (previous !== visual) { + painted[key] = visual + publish = true + } + } + } + return { painted, moving, publish } +} + export type FrameListener = (dt: number, now: number) => void const listeners = new Set() @@ -34,8 +180,27 @@ export function onFrame(listener: FrameListener): () => void { } } +/** + * Subscribe a spring tick. Unsubscribes itself when the tick returns false + * (every channel at rest), matching Automaton `subscribeSpringTick`. + */ +export function subscribeSpringTick(tick: (dt: number, now: number) => boolean): () => void { + const listener: FrameListener = (dt, now) => { + if (!tick(dt, now)) listeners.delete(listener) + } + return onFrame(listener) +} + +export function springClockBusy(): boolean { + return listeners.size > 0 +} + +export function resetSpringClockForTests(): void { + listeners.clear() +} + export function pumpFrames(dt: number, now: number): void { - for (const listener of listeners) listener(dt, now) + for (const listener of [...listeners]) listener(dt, now) } export const GELATIN = { stiffness: 28, damping: 8, mass: 1.25, velocity: 0 } From 774c854794075e5d34d6bd23a4b3b2c069f77432 Mon Sep 17 00:00:00 2001 From: Cary Palmer Date: Tue, 29 Sep 2026 16:34:23 -0500 Subject: [PATCH 3/3] test(motion): let the native spring test reach rest; cover type "tween" The overshoot test stepped 120 x 8ms, but stiffness 40 / damping 6 only snaps to rest around frame 342, so it asserted a settle that had not happened. Step 500 frames. Add a case for an explicit type: "tween" transition, which the untagged enum would otherwise read as a spring. Co-Authored-By: Claude Opus 5.5 --- packages/native/src/motion.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/native/src/motion.rs b/packages/native/src/motion.rs index 13047263..53552b27 100644 --- a/packages/native/src/motion.rs +++ b/packages/native/src/motion.rs @@ -840,7 +840,7 @@ mod tests { let mut max_width = 0.0_f64; let mut saw_settle = false; let mut now = started; - for _ in 0..120 { + for _ in 0..500 { now += Duration::from_millis(8); let frame = state.frame(now); saw_settle |= frame.just_settled; @@ -856,6 +856,25 @@ mod tests { assert!(saw_settle, "spring must report just_settled once at rest"); } + #[test] + fn explicit_tween_type_is_a_tween() { + let started = Instant::now(); + let description = serde_json::json!({ + "initial": { "width": 0.0 }, + "animate": { "width": 100.0 }, + "transition": { "type": "tween", "duration": 1.0, "ease": "linear" } + }); + let mut state = MotionState::new(&description, started).unwrap(); + state.frame(started + Duration::from_millis(500)); + assert_eq!( + state + .visible_style(started + Duration::from_millis(500)) + .unwrap() + .width, + Some(50.0) + ); + } + #[test] fn spring_rejects_non_positive_parameters() { let now = Instant::now();