From d94ae1ef7110e0e6a797556ad366826e74b2f3f7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 12 May 2024 19:02:37 +0800 Subject: [PATCH 01/14] Add createSpring primitive. --- packages/spring/LICENSE | 21 ++ packages/spring/README.md | 56 ++++++ packages/spring/package.json | 62 ++++++ packages/spring/src/index.ts | 287 ++++++++++++++++++++++++++++ packages/spring/test/index.test.ts | 19 ++ packages/spring/test/server.test.ts | 9 + packages/spring/tsconfig.json | 3 + 7 files changed, 457 insertions(+) create mode 100644 packages/spring/LICENSE create mode 100644 packages/spring/README.md create mode 100644 packages/spring/package.json create mode 100644 packages/spring/src/index.ts create mode 100644 packages/spring/test/index.test.ts create mode 100644 packages/spring/test/server.test.ts create mode 100644 packages/spring/tsconfig.json diff --git a/packages/spring/LICENSE b/packages/spring/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/spring/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/spring/README.md b/packages/spring/README.md new file mode 100644 index 000000000..08bd59713 --- /dev/null +++ b/packages/spring/README.md @@ -0,0 +1,56 @@ +

+ Solid Primitives spring +

+ +# @solid-primitives/spring + +[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/) +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/spring?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/spring) +[![version](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/spring) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +A small SolidJS hook to interpolate signal changes with spring physics. Inspired by & directly forked from [`svelte-motion/spring`](https://svelte.dev/docs/svelte-motion#spring) as such, has a very familiar API design. + +With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes. + +`createSpring` - Provides a getter and setter for the spring primitive. + +The following options are available: + +- `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring +- `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring +- `precision` (number, default `0.01`) — determines the threshold at which the spring is considered to have 'settled', where lower means more precise + +## Installation + +```bash +npm install @solid-primitives/spring +# or +yarn add @solid-primitives/spring +# or +pnpm add @solid-primitives/spring +``` + +## How to use it + +```ts +// Basic Example +const [progress, setProgress] = createSpring(0); + +// Example with options (less sudden movement) +const [radialProgress, setRadialProgress] = createSpring(0, { stiffness: 0.05 }); + +// Example with collections (e.g. Object or Array). +const [xy, setXY] = createSpring( + { x: 50, y: 50 }, + { stiffness: 0.08, damping: 0.2, precision: 0.01 }, +); +``` + +## Demo + +- [CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr) + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/spring/package.json b/packages/spring/package.json new file mode 100644 index 000000000..ac4d1802f --- /dev/null +++ b/packages/spring/package.json @@ -0,0 +1,62 @@ +{ + "name": "@solid-primitives/spring", + "version": "0.0.100", + "description": "Primitive that creates spring physics functions.", + "author": "Carlo Taleon ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/spring", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "spring", + "stage": 0, + "list": [ + "createTween" + ], + "category": "Animation" + }, + "keywords": [ + "animate", + "tween", + "spring", + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "tsx ../../scripts/dev.ts", + "build": "tsx ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "^1.6.12" + } +} diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts new file mode 100644 index 000000000..2de64aeb5 --- /dev/null +++ b/packages/spring/src/index.ts @@ -0,0 +1,287 @@ +import { isServer } from "solid-js/web"; + +// =========================================================================== +// Internals +// =========================================================================== + +// src/internal/client/types.d.ts + +export interface Task { + abort(): void; + promise: Promise; +} + +export interface TickContext { + inv_mass: number; + dt: number; + opts: SpringOptions & { set: SpringSetter }; + settled: boolean; +} + +export type Raf = { + /** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */ + tick: (callback: (time: DOMHighResTimeStamp) => void) => any; + /** Alias for `performance.now()`, exposed in such a way that we can override in tests */ + now: () => number; + /** A set of tasks that will run to completion, unless aborted */ + tasks: Set; +}; + +export type TaskCallback = (now: number) => boolean | void; + +export type TaskEntry = { c: TaskCallback; f: () => void }; + +// src/internal/client/timing.js + +/** SSR-safe RAF function. */ +const request_animation_frame = isServer ? () => {} : requestAnimationFrame; + +/** SSR-safe now getter. */ +const now = isServer ? () => Date.now() : () => performance.now(); + +export const raf: Raf = { + tick: (_: any) => request_animation_frame(_), + now: () => now(), + tasks: new Set(), +}; + +// src/motion/utils.js + +/** + * @param {any} obj + * @returns {obj is Date} + */ +export function is_date(obj: any): obj is Date { + return Object.prototype.toString.call(obj) === "[object Date]"; +} + +// src/internal/client/loop.js + +/** + * @param {number} now + * @returns {void} + */ +function run_tasks(now: number) { + raf.tasks.forEach(task => { + if (!task.c(now)) { + raf.tasks.delete(task); + task.f(); + } + }); + + if (raf.tasks.size !== 0) { + raf.tick(run_tasks); + } +} + +/** + * Creates a new task that runs on each raf frame + * until it returns a falsy value or is aborted + */ +export function loop(callback: TaskCallback): Task { + let task: TaskEntry; + + if (raf.tasks.size === 0) { + raf.tick(run_tasks); + } + + return { + promise: new Promise((fulfill: any) => { + raf.tasks.add((task = { c: callback, f: fulfill })); + }), + abort() { + raf.tasks.delete(task); + }, + }; +} + +// =========================================================================== +// createSpring hook +// =========================================================================== + +import { Accessor, createSignal } from "solid-js"; + +export type SpringOptions = { + /** + * Stiffness of the spring. Higher values will create more sudden movement. + * @default 0.15 + */ + stiffness?: number; + /** + * Strength of opposing force. If set to 0, spring will oscillate indefinitely. + * @default 0.8 + */ + damping?: number; + /** + * Precision is the threshold relative to the target value at which the + * animation will stop based on the current value. + * + * From 0, if the target value is 500, and the precision is 500, it will stop + * the animation instantly (no animation, similar to `hard: true`). + * + * From 0, if the target value is 500, and the precision is 0.01, it will stop the + * animation when the current value reaches 499.99 or 500.01 (longer animation). + * + * @default 0.01 + */ + precision?: number; +}; + +type SpringSetter = ( + newValue: T, + opts?: { hard?: boolean; soft?: boolean | number }, +) => Promise; + +/** + * Creates a signal and a setter that uses spring physics when interpolating from + * one value to another. This means when the value changes, instead of + * transitioning at a steady rate, it "bounces" like a spring would, + * depending on the physics paramters provided. This adds a level of realism to + * the transitions and can enhance the user experience. + * + * `T` - The type of the signal. It works for any basic data types that can be interpolated + * like `number`, a `Date`, or even a collection of them `Array` or a nested object of T. + * + * @param initialValue The initial value of the signal. + * @param options Options to configure the physics of the spring. + * + * @example + * const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 }); + */ +export function createSpring( + initialValue: T, + options: SpringOptions = {}, +): [Accessor, SpringSetter] { + const [springValue, setSpringValue] = createSignal(initialValue); + const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; + + const [lastTime, setLastTime] = createSignal(0); + + const [task, setTask] = createSignal(null); + + const [current_token, setCurrentToken] = createSignal(); + + const [lastValue, setLastValue] = createSignal(initialValue); + const [targetValue, setTargetValue] = createSignal(); + + const [inv_mass, setInvMass] = createSignal(1); + const [inv_mass_recovery_rate, setInvMassRecoveryRate] = createSignal(0); + const [cancelTask, setCancelTask] = createSignal(false); + + const set: SpringSetter = (newValue, opts = {}) => { + setTargetValue(_ => newValue); + + const token = current_token() ?? {}; + setCurrentToken(token); + + if (springValue() == null || opts.hard || (stiffness >= 1 && damping >= 1)) { + setCancelTask(true); + setLastTime(raf.now()); + setLastValue(_ => newValue); + setSpringValue(_ => newValue); + return Promise.resolve(); + } else if (opts.soft) { + const rate = opts.soft === true ? 0.5 : +opts.soft; + setInvMassRecoveryRate(1 / (rate * 60)); + setInvMass(0); // Infinite mass, unaffected by spring forces. + } + if (!task()) { + setLastTime(raf.now()); + setCancelTask(false); + + const _loop = loop(now => { + if (cancelTask()) { + setCancelTask(false); + setTask(null); + return false; + } + + setInvMass(_inv_mass => Math.min(_inv_mass + inv_mass_recovery_rate(), 1)); + + const ctx: TickContext = { + inv_mass: inv_mass(), + opts: { + set: set, + damping: damping, + precision: precision, + stiffness: stiffness, + }, + settled: true, + dt: ((now - lastTime()) * 60) / 1000, + }; + // @ts-ignore + const next_value = tick_spring( + ctx, + lastValue(), + springValue(), + // @ts-ignore + targetValue(), + ); + setLastTime(now); + setLastValue(_ => springValue()); + setSpringValue(_ => next_value); + if (ctx.settled) { + setTask(null); + } + + return !ctx.settled; + }); + + setTask(_loop); + } + return new Promise(fulfil => { + task()?.promise.then(() => { + if (token === current_token()) fulfil(); + }); + }); + }; + + const tick_spring = ( + ctx: TickContext, + last_value: T, + current_value: T, + target_value: T, + ): T => { + if (typeof current_value === "number" || is_date(current_value)) { + // @ts-ignore + const delta = target_value - current_value; + // @ts-ignore + const velocity = (current_value - last_value) / (ctx.dt || 1 / 60); // guard div by 0 + const spring = ctx.opts.stiffness! * delta; + const damper = ctx.opts.damping! * velocity; + const acceleration = (spring - damper) * ctx.inv_mass; + const d = (velocity + acceleration) * ctx.dt; + if (Math.abs(d) < ctx.opts.precision! && Math.abs(delta) < ctx.opts.precision!) { + return target_value; // settled + } else { + ctx.settled = false; // signal loop to keep ticking + // @ts-ignore + return is_date(current_value) ? new Date(current_value.getTime() + d) : current_value + d; + } + } else if (Array.isArray(current_value)) { + // @ts-ignore + return current_value.map((_, i) => + // @ts-ignore + tick_spring(ctx, last_value[i], current_value[i], target_value[i]), + ); + } else if (typeof current_value === "object") { + const next_value = {}; + for (const k in current_value) { + // @ts-ignore + next_value[k] = tick_spring( + ctx, + // @ts-ignore + last_value[k], + current_value[k], + target_value[k], + ); + } + // @ts-ignore + return next_value; + } else { + throw new Error(`Cannot spring ${typeof current_value} values`); + } + }; + + return [springValue, set]; +} diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts new file mode 100644 index 000000000..82698d692 --- /dev/null +++ b/packages/spring/test/index.test.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createSpring } from "../src/index.js"; + +describe("createSpring", () => { + test("createSpring return values", () => { + const { value, setValue, dispose } = createRoot(dispose => { + const [value, setValue] = createSpring({ progress: 0 }); + expect(value().progress, "initial value should be { progress: 0 }").toBe(0); + + return { value, setValue, dispose }; + }); + + setValue({ progress: 100 }); + + dispose(); + }); + // TODO: Add more tests. +}); diff --git a/packages/spring/test/server.test.ts b/packages/spring/test/server.test.ts new file mode 100644 index 000000000..e50572754 --- /dev/null +++ b/packages/spring/test/server.test.ts @@ -0,0 +1,9 @@ +import { describe, test, expect } from "vitest"; +import { createSpring } from "../src/index.js"; + +describe("createSpring", () => { + test("doesn't break in SSR", () => { + const [value, setValue] = createSpring({ progress: 0 }); + expect(value().progress, "initial value should be { progress: 0 }").toBe(0); + }); +}); diff --git a/packages/spring/tsconfig.json b/packages/spring/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/packages/spring/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} From ea6ea398815e5b63c3fe8ec59021fbc4c521cd75 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 15 May 2024 23:20:24 +0800 Subject: [PATCH 02/14] Add better types for createSpring primitives and a few tests. --- packages/spring/src/index.ts | 68 ++++++++++++++++++++++++++---- packages/spring/test/index.test.ts | 58 +++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index 2de64aeb5..41aee1b4d 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -11,7 +11,7 @@ export interface Task { promise: Promise; } -export interface TickContext { +export interface TickContext { inv_mass: number; dt: number; opts: SpringOptions & { set: SpringSetter }; @@ -99,7 +99,7 @@ export function loop(callback: TaskCallback): Task { // createSpring hook // =========================================================================== -import { Accessor, createSignal } from "solid-js"; +import { Accessor, createEffect, createSignal, on } from "solid-js"; export type SpringOptions = { /** @@ -127,6 +127,21 @@ export type SpringOptions = { precision?: number; }; +type SpringTargetPrimitive = number | Date; +type SpringTarget = + | SpringTargetPrimitive + | { [key: string]: SpringTargetPrimitive | SpringTarget } + | SpringTargetPrimitive[] + | SpringTarget[]; + +/** + * "Widen" Utility Type so that number types are not converted to + * literal types when passed to `createSpring`. + * + * e.g. createSpring(0) returns `0`, not `number`. + */ +type WidenSpringTarget = T extends number ? number : T extends Date ? Date : T; + type SpringSetter = ( newValue: T, opts?: { hard?: boolean; soft?: boolean | number }, @@ -139,19 +154,20 @@ type SpringSetter = ( * depending on the physics paramters provided. This adds a level of realism to * the transitions and can enhance the user experience. * - * `T` - The type of the signal. It works for any basic data types that can be interpolated - * like `number`, a `Date`, or even a collection of them `Array` or a nested object of T. + * `T` - The type of the signal. It works for the basic data types that can be + * interpolated: `number`, a `Date`, `Array` or a nested object of T. * * @param initialValue The initial value of the signal. * @param options Options to configure the physics of the spring. + * @returns Returns the spring value and a setter. * * @example * const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 }); */ -export function createSpring( +export function createSpring( initialValue: T, options: SpringOptions = {}, -): [Accessor, SpringSetter] { +): [Accessor>, SpringSetter>] { const [springValue, setSpringValue] = createSignal(initialValue); const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; @@ -174,6 +190,7 @@ export function createSpring( const token = current_token() ?? {}; setCurrentToken(token); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (springValue() == null || opts.hard || (stiffness >= 1 && damping >= 1)) { setCancelTask(true); setLastTime(raf.now()); @@ -236,7 +253,7 @@ export function createSpring( }); }; - const tick_spring = ( + const tick_spring = ( ctx: TickContext, last_value: T, current_value: T, @@ -283,5 +300,40 @@ export function createSpring( } }; - return [springValue, set]; + return [springValue as Accessor>, set as SpringSetter>]; +} + +// =========================================================================== +// createDerivedSpring hook +// =========================================================================== + +/** + * Creates a spring value that interpolates based on changes on a passed signal. + * Works similar to the `@solid-primitives/tween` + * + * @param target Target to be modified. + * @param options Options to configure the physics of the spring. + * @returns Returns the spring value only. + * + * @example + * const percent = createMemo(() => current() / total() * 100); + * + * const springedPercent = createDerivedSignal(percent, { stiffness: 0.15, damping: 0.8 }); + */ +export function createDerivedSpring( + target: Accessor, + options?: SpringOptions, +) { + const [springValue, setSpringValue] = createSpring(target(), options); + + createEffect( + on( + () => target(), + () => { + setSpringValue(target() as WidenSpringTarget); + }, + ), + ); + + return springValue; } diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 82698d692..9127f57a5 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,9 +1,13 @@ -import { describe, test, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { createRoot } from "solid-js"; import { createSpring } from "../src/index.js"; +// =========================================================================== +// Tests +// =========================================================================== + describe("createSpring", () => { - test("createSpring return values", () => { + it("returns values", () => { const { value, setValue, dispose } = createRoot(dispose => { const [value, setValue] = createSpring({ progress: 0 }); expect(value().progress, "initial value should be { progress: 0 }").toBe(0); @@ -12,8 +16,56 @@ describe("createSpring", () => { }); setValue({ progress: 100 }); + dispose(); + }); + + it("updates toward target", async () => { + let dispose!: () => void; + let setSpringed!: ( + newValue: number, + opts?: { hard?: boolean; soft?: boolean }, + ) => Promise; + let springed!: () => number; + + createRoot(d => { + dispose = d; + [springed, setSpringed] = createSpring(0); + }); + + expect(springed()).toBe(0); + setSpringed(50); // Set to 100 here. + + // Not sure if this will be erratic. + await new Promise(resolve => setTimeout(resolve, 300)); + + // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) + expect(springed()).not.toBe(50); + expect(springed()).toBeGreaterThan(50 / 2); + dispose(); + }); + + it("instantly updates when set with hard.", () => { + let dispose!: () => void; + let setSpringed!: ( + newValue: number, + opts?: { hard?: boolean; soft?: boolean }, + ) => Promise; + let springed!: () => number; + + createRoot(d => { + dispose = d; + [springed, setSpringed] = createSpring(0); + }); + + // const start = performance.now(); + expect(springed()).toBe(0); + setSpringed(50, { hard: true }); // Set to 100 here. + + // expect(springed()).toBe(0); + + // _flush_raf(start + 300); + expect(springed()).toBe(50); dispose(); }); - // TODO: Add more tests. }); From 41d6679706cbd310fee1e6c539f5816265afba3c Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 15 May 2024 23:53:03 +0800 Subject: [PATCH 03/14] Added createDerivedSpring tests, ssr tests, and different data type tests. --- packages/spring/test/index.test.ts | 113 +++++++++++++++++++++++++--- packages/spring/test/server.test.ts | 11 ++- 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 9127f57a5..640b09d59 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createRoot } from "solid-js"; -import { createSpring } from "../src/index.js"; +import { createRoot, createSignal } from "solid-js"; +import { createDerivedSpring, createSpring } from "../src/index.js"; // =========================================================================== // Tests @@ -44,7 +44,10 @@ describe("createSpring", () => { dispose(); }); - it("instantly updates when set with hard.", () => { + it("instantly updates `number` when set with hard.", () => { + const start = 0; + const end = 50; + let dispose!: () => void; let setSpringed!: ( newValue: number, @@ -54,18 +57,108 @@ describe("createSpring", () => { createRoot(d => { dispose = d; - [springed, setSpringed] = createSpring(0); + [springed, setSpringed] = createSpring(start); }); - // const start = performance.now(); - expect(springed()).toBe(0); - setSpringed(50, { hard: true }); // Set to 100 here. + expect(springed()).toBe(start); + setSpringed(end, { hard: true }); // Set to 100 here. + + expect(springed()).toBe(end); + + dispose(); + }); + + it("instantly updates `Date` when set with hard.", () => { + const start = new Date("2024-04-14T00:00:00.000Z"); + const end = new Date("2024-04-14T00:00:00.000Z"); + + let dispose!: () => void; + let setSpringed!: (newValue: Date, opts?: { hard?: boolean; soft?: boolean }) => Promise; + let springed!: () => Date; + + createRoot(d => { + dispose = d; + [springed, setSpringed] = createSpring(start); + }); + + expect(springed().getDate()).toBe(start.getDate()); + setSpringed(end, { hard: true }); // Set to 100 here. + + expect(springed().getDate()).toBe(end.getDate()); + + dispose(); + }); + + it("instantly updates `{ progress: 1}` when set with hard.", () => { + const start = { progress: 1 }; + const end = { progress: 100 }; + + let dispose!: () => void; + let setSpringed!: ( + newValue: { progress: number }, + opts?: { hard?: boolean; soft?: boolean }, + ) => Promise; + let springed!: () => { progress: number }; + + createRoot(d => { + dispose = d; + [springed, setSpringed] = createSpring(start); + }); + + expect(springed()).toMatchObject(start); + setSpringed(end, { hard: true }); // Set to 100 here. + + expect(springed()).toMatchObject(end); + + dispose(); + }); + + it("instantly updates `Array` when set with hard.", () => { + const start = [1, 2, 3]; + const end = [20, 15, 20]; + + let dispose!: () => void; + let setSpringed!: ( + newValue: number[], + opts?: { hard?: boolean; soft?: boolean }, + ) => Promise; + let springed!: () => number[]; + + createRoot(d => { + dispose = d; + [springed, setSpringed] = createSpring(start); + }); + + expect(springed()).toMatchObject(start); + setSpringed(end, { hard: true }); // Set to 100 here. + + expect(springed()).toMatchObject(end); + + dispose(); + }); +}); + +describe("createDerivedSpring", () => { + it("updates toward accessor target", async () => { + let dispose!: () => void; + + let springed!: () => number; + const [signal, setSignal] = createSignal(0); + + createRoot(d => { + dispose = d; + springed = createDerivedSpring(signal); + }); - // expect(springed()).toBe(0); + expect(springed()).toBe(0); + setSignal(50); // Set to 100 here. - // _flush_raf(start + 300); - expect(springed()).toBe(50); + // Not sure if this will be erratic. + await new Promise(resolve => setTimeout(resolve, 300)); + // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) + expect(springed()).not.toBe(50); + expect(springed()).toBeGreaterThan(50 / 2); dispose(); }); }); diff --git a/packages/spring/test/server.test.ts b/packages/spring/test/server.test.ts index e50572754..2637cca72 100644 --- a/packages/spring/test/server.test.ts +++ b/packages/spring/test/server.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "vitest"; -import { createSpring } from "../src/index.js"; +import { createDerivedSpring, createSpring } from "../src/index.js"; +import { createSignal } from "solid-js"; describe("createSpring", () => { test("doesn't break in SSR", () => { @@ -7,3 +8,11 @@ describe("createSpring", () => { expect(value().progress, "initial value should be { progress: 0 }").toBe(0); }); }); + +describe("createDerivedSpring", () => { + test("doesn't break in SSR", () => { + const [signal, setSignal] = createSignal({ progress: 0 }); + const value = createDerivedSpring(signal); + expect(value().progress, "initial value should be { progress: 0 }").toBe(0); + }); +}); From 45d16ec5bd4ede71e117df69f697ee76b7809cb0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 15 May 2024 23:56:37 +0800 Subject: [PATCH 04/14] Refactor by removing useless exports and only export important types. Also added some docs. --- packages/spring/README.md | 9 +++++++-- packages/spring/src/index.ts | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/spring/README.md b/packages/spring/README.md index 08bd59713..0e6dd321d 100644 --- a/packages/spring/README.md +++ b/packages/spring/README.md @@ -13,9 +13,10 @@ A small SolidJS hook to interpolate signal changes with spring physics. Inspired With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes. -`createSpring` - Provides a getter and setter for the spring primitive. +- `createSpring` - Provides a getter and setter for the spring primitive. +- `createDerivedSpring` - Provides only a getter for the spring primitive deriving from an accessor parameter. Similar to the [@solid-primitives/tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween) API. -The following options are available: +The following physics options are available: - `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring - `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring @@ -45,6 +46,10 @@ const [xy, setXY] = createSpring( { x: 50, y: 50 }, { stiffness: 0.08, damping: 0.2, precision: 0.01 }, ); + +// Example deriving from an existing signal. +const [myNumber, myNumber] = createSignal(20); +const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 }); ``` ## Demo diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index 41aee1b4d..ecb030ef9 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -6,19 +6,19 @@ import { isServer } from "solid-js/web"; // src/internal/client/types.d.ts -export interface Task { +type Task = { abort(): void; promise: Promise; -} +}; -export interface TickContext { +type TickContext = { inv_mass: number; dt: number; opts: SpringOptions & { set: SpringSetter }; settled: boolean; -} +}; -export type Raf = { +type Raf = { /** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */ tick: (callback: (time: DOMHighResTimeStamp) => void) => any; /** Alias for `performance.now()`, exposed in such a way that we can override in tests */ @@ -27,9 +27,9 @@ export type Raf = { tasks: Set; }; -export type TaskCallback = (now: number) => boolean | void; +type TaskCallback = (now: number) => boolean | void; -export type TaskEntry = { c: TaskCallback; f: () => void }; +type TaskEntry = { c: TaskCallback; f: () => void }; // src/internal/client/timing.js @@ -39,7 +39,7 @@ const request_animation_frame = isServer ? () => {} : requestAnimationFrame; /** SSR-safe now getter. */ const now = isServer ? () => Date.now() : () => performance.now(); -export const raf: Raf = { +const raf: Raf = { tick: (_: any) => request_animation_frame(_), now: () => now(), tasks: new Set(), @@ -51,7 +51,7 @@ export const raf: Raf = { * @param {any} obj * @returns {obj is Date} */ -export function is_date(obj: any): obj is Date { +function is_date(obj: any): obj is Date { return Object.prototype.toString.call(obj) === "[object Date]"; } @@ -78,7 +78,7 @@ function run_tasks(now: number) { * Creates a new task that runs on each raf frame * until it returns a falsy value or is aborted */ -export function loop(callback: TaskCallback): Task { +function loop(callback: TaskCallback): Task { let task: TaskEntry; if (raf.tasks.size === 0) { @@ -127,8 +127,8 @@ export type SpringOptions = { precision?: number; }; -type SpringTargetPrimitive = number | Date; -type SpringTarget = +export type SpringTargetPrimitive = number | Date; +export type SpringTarget = | SpringTargetPrimitive | { [key: string]: SpringTargetPrimitive | SpringTarget } | SpringTargetPrimitive[] @@ -140,9 +140,9 @@ type SpringTarget = * * e.g. createSpring(0) returns `0`, not `number`. */ -type WidenSpringTarget = T extends number ? number : T extends Date ? Date : T; +export type WidenSpringTarget = T extends number ? number : T extends Date ? Date : T; -type SpringSetter = ( +export type SpringSetter = ( newValue: T, opts?: { hard?: boolean; soft?: boolean | number }, ) => Promise; From 24f4702d87b250154544fbd0d190271f22dc51f5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 11 Sep 2024 22:40:10 +0800 Subject: [PATCH 05/14] Refactor by improving implementation based on @thetarnav's suggestions. --- packages/spring/package.json | 3 +- packages/spring/src/index.ts | 148 +++++++++++++---------------- packages/spring/test/index.test.ts | 19 +--- 3 files changed, 70 insertions(+), 100 deletions(-) diff --git a/packages/spring/package.json b/packages/spring/package.json index ac4d1802f..1d735ec16 100644 --- a/packages/spring/package.json +++ b/packages/spring/package.json @@ -17,7 +17,8 @@ "name": "spring", "stage": 0, "list": [ - "createTween" + "createSpring", + "createDerivedSpring" ], "category": "Animation" }, diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index ecb030ef9..6ff2fd816 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -4,7 +4,7 @@ import { isServer } from "solid-js/web"; // Internals // =========================================================================== -// src/internal/client/types.d.ts +// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/types.d.ts type Task = { abort(): void; @@ -18,49 +18,33 @@ type TickContext = { settled: boolean; }; -type Raf = { +type TaskCallback = (now: number) => boolean | void; + +type TaskEntry = { c: TaskCallback; f: () => void }; + +// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/timing.js + +const raf: { /** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */ tick: (callback: (time: DOMHighResTimeStamp) => void) => any; /** Alias for `performance.now()`, exposed in such a way that we can override in tests */ now: () => number; /** A set of tasks that will run to completion, unless aborted */ tasks: Set; -}; - -type TaskCallback = (now: number) => boolean | void; - -type TaskEntry = { c: TaskCallback; f: () => void }; - -// src/internal/client/timing.js - -/** SSR-safe RAF function. */ -const request_animation_frame = isServer ? () => {} : requestAnimationFrame; - -/** SSR-safe now getter. */ -const now = isServer ? () => Date.now() : () => performance.now(); - -const raf: Raf = { - tick: (_: any) => request_animation_frame(_), - now: () => now(), +} = { + tick: (_: any) => (isServer ? () => {} : requestAnimationFrame(_)), // SSR-safe RAF function. + now: () => performance.now(), // Getter for now() using performance in browser and Date in server. Although both are available in node and browser. tasks: new Set(), }; -// src/motion/utils.js +// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js -/** - * @param {any} obj - * @returns {obj is Date} - */ function is_date(obj: any): obj is Date { return Object.prototype.toString.call(obj) === "[object Date]"; } -// src/internal/client/loop.js +// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/loop.js -/** - * @param {number} now - * @returns {void} - */ function run_tasks(now: number) { raf.tasks.forEach(task => { if (!task.c(now)) { @@ -99,7 +83,7 @@ function loop(callback: TaskCallback): Task { // createSpring hook // =========================================================================== -import { Accessor, createEffect, createSignal, on } from "solid-js"; +import { Accessor, createEffect, createSignal, untrack } from "solid-js"; export type SpringOptions = { /** @@ -143,7 +127,7 @@ export type SpringTarget = export type WidenSpringTarget = T extends number ? number : T extends Date ? Date : T; export type SpringSetter = ( - newValue: T, + newValue: T | ((prev: T) => T), opts?: { hard?: boolean; soft?: boolean | number }, ) => Promise; @@ -171,52 +155,59 @@ export function createSpring( const [springValue, setSpringValue] = createSignal(initialValue); const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; - const [lastTime, setLastTime] = createSignal(0); - - const [task, setTask] = createSignal(null); + let lastTime = 0; + let task: Task | null = null; + let current_token: object | undefined = undefined; + let lastValue: T = initialValue; + let targetValue: T | undefined; + let inv_mass = 1; + let inv_mass_recovery_rate = 0; + let cancelTask = false; - const [current_token, setCurrentToken] = createSignal(); - - const [lastValue, setLastValue] = createSignal(initialValue); - const [targetValue, setTargetValue] = createSignal(); + /** + * Gets `newValue` from the SpringSetter's first argument. + */ + function getNewValue(newValue: T | ((prev: T) => T)) { + if (typeof newValue === "function") { + return newValue(lastValue); + } - const [inv_mass, setInvMass] = createSignal(1); - const [inv_mass_recovery_rate, setInvMassRecoveryRate] = createSignal(0); - const [cancelTask, setCancelTask] = createSignal(false); + return newValue; + } - const set: SpringSetter = (newValue, opts = {}) => { - setTargetValue(_ => newValue); + const set: SpringSetter = untrack(() => (newValue, opts = {}) => { + targetValue = getNewValue(newValue); - const token = current_token() ?? {}; - setCurrentToken(token); + const token = current_token ?? {}; + current_token = token; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (springValue() == null || opts.hard || (stiffness >= 1 && damping >= 1)) { - setCancelTask(true); - setLastTime(raf.now()); - setLastValue(_ => newValue); - setSpringValue(_ => newValue); + cancelTask = true; + lastTime = raf.now(); + lastValue = getNewValue(newValue); + setSpringValue(_ => getNewValue(newValue)); return Promise.resolve(); } else if (opts.soft) { const rate = opts.soft === true ? 0.5 : +opts.soft; - setInvMassRecoveryRate(1 / (rate * 60)); - setInvMass(0); // Infinite mass, unaffected by spring forces. + inv_mass_recovery_rate = 1 / (rate * 60); + inv_mass = 0; // Infinite mass, unaffected by spring forces. } - if (!task()) { - setLastTime(raf.now()); - setCancelTask(false); + if (!task) { + lastTime = raf.now(); + cancelTask = false; const _loop = loop(now => { - if (cancelTask()) { - setCancelTask(false); - setTask(null); + if (cancelTask) { + cancelTask = false; + task = null; return false; } - setInvMass(_inv_mass => Math.min(_inv_mass + inv_mass_recovery_rate(), 1)); + inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1); const ctx: TickContext = { - inv_mass: inv_mass(), + inv_mass: inv_mass, opts: { set: set, damping: damping, @@ -224,34 +215,27 @@ export function createSpring( stiffness: stiffness, }, settled: true, - dt: ((now - lastTime()) * 60) / 1000, + dt: ((now - lastTime) * 60) / 1000, }; - // @ts-ignore - const next_value = tick_spring( - ctx, - lastValue(), - springValue(), - // @ts-ignore - targetValue(), - ); - setLastTime(now); - setLastValue(_ => springValue()); + const next_value = tick_spring(ctx, lastValue, springValue(), targetValue!); + lastTime = now; + lastValue = springValue(); setSpringValue(_ => next_value); if (ctx.settled) { - setTask(null); + task = null; } return !ctx.settled; }); - setTask(_loop); + task = _loop; } return new Promise(fulfil => { - task()?.promise.then(() => { - if (token === current_token()) fulfil(); + task?.promise.then(() => { + if (token === current_token) fulfil(); }); }); - }; + }); const tick_spring = ( ctx: TickContext, @@ -300,7 +284,10 @@ export function createSpring( } }; - return [springValue as Accessor>, set as SpringSetter>]; + return [ + springValue as Accessor>, + set as unknown as SpringSetter>, + ]; } // =========================================================================== @@ -326,14 +313,7 @@ export function createDerivedSpring( ) { const [springValue, setSpringValue] = createSpring(target(), options); - createEffect( - on( - () => target(), - () => { - setSpringValue(target() as WidenSpringTarget); - }, - ), - ); + createEffect(() => setSpringValue(target() as WidenSpringTarget)); return springValue; } diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 640b09d59..b1a9a029a 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,11 +1,7 @@ -import { describe, expect, it } from "vitest"; import { createRoot, createSignal } from "solid-js"; +import { describe, expect, it } from "vitest"; import { createDerivedSpring, createSpring } from "../src/index.js"; -// =========================================================================== -// Tests -// =========================================================================== - describe("createSpring", () => { it("returns values", () => { const { value, setValue, dispose } = createRoot(dispose => { @@ -20,16 +16,9 @@ describe("createSpring", () => { }); it("updates toward target", async () => { - let dispose!: () => void; - let setSpringed!: ( - newValue: number, - opts?: { hard?: boolean; soft?: boolean }, - ) => Promise; - let springed!: () => number; - - createRoot(d => { - dispose = d; - [springed, setSpringed] = createSpring(0); + const { dispose, springed, setSpringed } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(0); + return { dispose, springed, setSpringed }; }); expect(springed()).toBe(0); From 9fead8a9b1e4d379846ddeffa939bee47ba16fe1 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 13 Sep 2024 03:32:33 +0800 Subject: [PATCH 06/14] Add extra tests and refactoring. --- packages/spring/test/index.test.ts | 99 +++++++++++++++++------------- 1 file changed, 56 insertions(+), 43 deletions(-) diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index b1a9a029a..01c29ae1a 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -4,7 +4,7 @@ import { createDerivedSpring, createSpring } from "../src/index.js"; describe("createSpring", () => { it("returns values", () => { - const { value, setValue, dispose } = createRoot(dispose => { + const { setValue, dispose } = createRoot(dispose => { const [value, setValue] = createSpring({ progress: 0 }); expect(value().progress, "initial value should be { progress: 0 }").toBe(0); @@ -22,7 +22,7 @@ describe("createSpring", () => { }); expect(springed()).toBe(0); - setSpringed(50); // Set to 100 here. + setSpringed(50); // Not sure if this will be erratic. await new Promise(resolve => setTimeout(resolve, 300)); @@ -37,20 +37,14 @@ describe("createSpring", () => { const start = 0; const end = 50; - let dispose!: () => void; - let setSpringed!: ( - newValue: number, - opts?: { hard?: boolean; soft?: boolean }, - ) => Promise; - let springed!: () => number; + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(start); - createRoot(d => { - dispose = d; - [springed, setSpringed] = createSpring(start); + return { springed, setSpringed, dispose }; }); expect(springed()).toBe(start); - setSpringed(end, { hard: true }); // Set to 100 here. + setSpringed(end, { hard: true }); expect(springed()).toBe(end); @@ -61,13 +55,10 @@ describe("createSpring", () => { const start = new Date("2024-04-14T00:00:00.000Z"); const end = new Date("2024-04-14T00:00:00.000Z"); - let dispose!: () => void; - let setSpringed!: (newValue: Date, opts?: { hard?: boolean; soft?: boolean }) => Promise; - let springed!: () => Date; + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(start); - createRoot(d => { - dispose = d; - [springed, setSpringed] = createSpring(start); + return { springed, setSpringed, dispose }; }); expect(springed().getDate()).toBe(start.getDate()); @@ -78,20 +69,14 @@ describe("createSpring", () => { dispose(); }); - it("instantly updates `{ progress: 1}` when set with hard.", () => { + it("instantly updates `{ progress: 1 }` when set with hard.", () => { const start = { progress: 1 }; const end = { progress: 100 }; - let dispose!: () => void; - let setSpringed!: ( - newValue: { progress: number }, - opts?: { hard?: boolean; soft?: boolean }, - ) => Promise; - let springed!: () => { progress: number }; + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(start); - createRoot(d => { - dispose = d; - [springed, setSpringed] = createSpring(start); + return { springed, setSpringed, dispose }; }); expect(springed()).toMatchObject(start); @@ -106,16 +91,10 @@ describe("createSpring", () => { const start = [1, 2, 3]; const end = [20, 15, 20]; - let dispose!: () => void; - let setSpringed!: ( - newValue: number[], - opts?: { hard?: boolean; soft?: boolean }, - ) => Promise; - let springed!: () => number[]; + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(start); - createRoot(d => { - dispose = d; - [springed, setSpringed] = createSpring(start); + return { springed, setSpringed, dispose }; }); expect(springed()).toMatchObject(start); @@ -125,18 +104,52 @@ describe("createSpring", () => { dispose(); }); + + it("instantly updates `number` when set with hard using a function as an argument.", () => { + const start = 0; + const end = 50; + + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring(start); + + return { springed, setSpringed, dispose }; + }); + + expect(springed()).toBe(start); + setSpringed(_ => end, { hard: true }); // Using a function as an argument. + + expect(springed()).toBe(end); + + dispose(); + }); + + it("instantly updates `{ progress: 1 }` when set with hard using a function as an argument.", () => { + const start = { progress: 1 }; + const end = { progress: 100 }; + + const { springed, setSpringed, dispose } = createRoot(dispose => { + const [springed, setSpringed] = createSpring({ progress: 1 }); + + return { springed, setSpringed, dispose }; + }); + + expect(springed()).toMatchObject(start); + setSpringed(_ => ({ progress: 100 }), { hard: true }); // Using a function as an argument. + + expect(springed()).toMatchObject(end); + + dispose(); + }); }); describe("createDerivedSpring", () => { it("updates toward accessor target", async () => { - let dispose!: () => void; - - let springed!: () => number; const [signal, setSignal] = createSignal(0); - createRoot(d => { - dispose = d; - springed = createDerivedSpring(signal); + const { springed, dispose } = createRoot(dispose => { + const springed = createDerivedSpring(signal); + + return { springed, dispose }; }); expect(springed()).toBe(0); From 0ada2ae6344b1bfb311c499473cbb64a585d2a5f Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Wed, 18 Sep 2024 23:14:41 +0200 Subject: [PATCH 07/14] Mock raf and timers for spring tests --- packages/spring/test/index.test.ts | 161 +++++++++++++++-------------- 1 file changed, 81 insertions(+), 80 deletions(-) diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 01c29ae1a..f0f687cba 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,35 +1,65 @@ import { createRoot, createSignal } from "solid-js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, afterAll, beforeAll, beforeEach } from "vitest"; import { createDerivedSpring, createSpring } from "../src/index.js"; -describe("createSpring", () => { - it("returns values", () => { - const { setValue, dispose } = createRoot(dispose => { - const [value, setValue] = createSpring({ progress: 0 }); - expect(value().progress, "initial value should be { progress: 0 }").toBe(0); +let _raf_last_id = 0; +let _raf_callbacks_old = new Map(); +let _raf_callbacks_new = new Map(); + +function _progress_time(by: number) { + const start = performance.now(); + vi.advanceTimersByTime(by); + + _raf_callbacks_old = _raf_callbacks_new; + _raf_callbacks_new = new Map(); + _raf_callbacks_old.forEach(c => c(start + by)); + _raf_callbacks_old.clear(); +} + +vi.stubGlobal("requestAnimationFrame", function (callback: FrameRequestCallback): number { + const id = _raf_last_id++; + _raf_callbacks_new.set(id, callback); + return id; +}); +vi.stubGlobal("cancelAnimationFrame", function (id: number): void { + _raf_callbacks_new.delete(id); +}); + +beforeAll(() => { + vi.useFakeTimers(); +}); - return { value, setValue, dispose }; - }); +beforeEach(() => { + vi.clearAllTimers(); +}); + +afterAll(() => { + vi.useRealTimers(); + _raf_callbacks_old.clear(); + _raf_callbacks_new.clear(); + _raf_last_id = 0; +}); - setValue({ progress: 100 }); +describe("createSpring", () => { + + it("returns values", () => { + const [[spring, setSpring], dispose] = createRoot(d => [createSpring({ progress: 0 }), d]); + expect(spring().progress).toBe(0); dispose(); }); - it("updates toward target", async () => { - const { dispose, springed, setSpringed } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(0); - return { dispose, springed, setSpringed }; - }); + it("updates toward target", () => { + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(0), d]); - expect(springed()).toBe(0); - setSpringed(50); + expect(spring()).toBe(0); + setSpring(50); + expect(spring()).toBe(0); - // Not sure if this will be erratic. - await new Promise(resolve => setTimeout(resolve, 300)); + _progress_time(300) // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) - expect(springed()).not.toBe(50); - expect(springed()).toBeGreaterThan(50 / 2); + expect(spring()).not.toBe(50); + expect(spring()).toBeGreaterThan(50 / 2); dispose(); }); @@ -37,16 +67,12 @@ describe("createSpring", () => { const start = 0; const end = 50; - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(start); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - return { springed, setSpringed, dispose }; - }); + expect(spring()).toBe(start); + setSpring(end, { hard: true }); - expect(springed()).toBe(start); - setSpringed(end, { hard: true }); - - expect(springed()).toBe(end); + expect(spring()).toBe(end); dispose(); }); @@ -55,16 +81,12 @@ describe("createSpring", () => { const start = new Date("2024-04-14T00:00:00.000Z"); const end = new Date("2024-04-14T00:00:00.000Z"); - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(start); - - return { springed, setSpringed, dispose }; - }); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - expect(springed().getDate()).toBe(start.getDate()); - setSpringed(end, { hard: true }); // Set to 100 here. + expect(spring().getDate()).toBe(start.getDate()); + setSpring(end, { hard: true }); // Set to 100 here. - expect(springed().getDate()).toBe(end.getDate()); + expect(spring().getDate()).toBe(end.getDate()); dispose(); }); @@ -73,16 +95,12 @@ describe("createSpring", () => { const start = { progress: 1 }; const end = { progress: 100 }; - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(start); - - return { springed, setSpringed, dispose }; - }); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - expect(springed()).toMatchObject(start); - setSpringed(end, { hard: true }); // Set to 100 here. + expect(spring()).toMatchObject(start); + setSpring(end, { hard: true }); // Set to 100 here. - expect(springed()).toMatchObject(end); + expect(spring()).toMatchObject(end); dispose(); }); @@ -91,16 +109,12 @@ describe("createSpring", () => { const start = [1, 2, 3]; const end = [20, 15, 20]; - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(start); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - return { springed, setSpringed, dispose }; - }); + expect(spring()).toMatchObject(start); + setSpring(end, { hard: true }); // Set to 100 here. - expect(springed()).toMatchObject(start); - setSpringed(end, { hard: true }); // Set to 100 here. - - expect(springed()).toMatchObject(end); + expect(spring()).toMatchObject(end); dispose(); }); @@ -109,16 +123,12 @@ describe("createSpring", () => { const start = 0; const end = 50; - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring(start); - - return { springed, setSpringed, dispose }; - }); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - expect(springed()).toBe(start); - setSpringed(_ => end, { hard: true }); // Using a function as an argument. + expect(spring()).toBe(start); + setSpring(_ => end, { hard: true }); // Using a function as an argument. - expect(springed()).toBe(end); + expect(spring()).toBe(end); dispose(); }); @@ -127,40 +137,31 @@ describe("createSpring", () => { const start = { progress: 1 }; const end = { progress: 100 }; - const { springed, setSpringed, dispose } = createRoot(dispose => { - const [springed, setSpringed] = createSpring({ progress: 1 }); - - return { springed, setSpringed, dispose }; - }); + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); - expect(springed()).toMatchObject(start); - setSpringed(_ => ({ progress: 100 }), { hard: true }); // Using a function as an argument. + expect(spring()).toMatchObject(start); + setSpring(_ => ({ progress: 100 }), { hard: true }); // Using a function as an argument. - expect(springed()).toMatchObject(end); + expect(spring()).toMatchObject(end); dispose(); }); }); describe("createDerivedSpring", () => { - it("updates toward accessor target", async () => { + it("updates toward accessor target", () => { const [signal, setSignal] = createSignal(0); + const [spring, dispose] = createRoot(d => [createDerivedSpring(signal), d]); - const { springed, dispose } = createRoot(dispose => { - const springed = createDerivedSpring(signal); - - return { springed, dispose }; - }); - - expect(springed()).toBe(0); + expect(spring()).toBe(0); setSignal(50); // Set to 100 here. + expect(spring()).toBe(0); - // Not sure if this will be erratic. - await new Promise(resolve => setTimeout(resolve, 300)); + _progress_time(300) // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) - expect(springed()).not.toBe(50); - expect(springed()).toBeGreaterThan(50 / 2); + expect(spring()).not.toBe(50); + expect(spring()).toBeGreaterThan(50 / 2); dispose(); }); }); From ca456036c98019b79d5c1727b69ead1ccb741a25 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Wed, 18 Sep 2024 23:28:17 +0200 Subject: [PATCH 08/14] Update lockfile --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a213e4029..e6f8578b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,12 @@ importers: specifier: ^1.8.7 version: 1.8.20 + packages/spring: + dependencies: + solid-js: + specifier: ^1.6.12 + version: 1.8.22 + packages/start: devDependencies: solid-js: From 98fed631bbf628caf3a2a72ba4c66647805621a7 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 20 Sep 2024 03:12:48 +0800 Subject: [PATCH 09/14] Remove T extends Date. --- packages/spring/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index 6ff2fd816..1f2fe665c 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -124,7 +124,7 @@ export type SpringTarget = * * e.g. createSpring(0) returns `0`, not `number`. */ -export type WidenSpringTarget = T extends number ? number : T extends Date ? Date : T; +export type WidenSpringTarget = T extends number ? number : T; export type SpringSetter = ( newValue: T | ((prev: T) => T), From 31bf2ce11871dd56209128c179a0492e9b438b41 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 19 Sep 2024 21:51:20 +0200 Subject: [PATCH 10/14] Clanup, fix and test untracking setter --- packages/spring/src/index.ts | 124 +++++++++++------------------ packages/spring/test/index.test.ts | 30 ++++++- 2 files changed, 76 insertions(+), 78 deletions(-) diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index 1f2fe665c..b84ad8168 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -11,10 +11,9 @@ type Task = { promise: Promise; }; -type TickContext = { +type TickContext = { inv_mass: number; dt: number; - opts: SpringOptions & { set: SpringSetter }; settled: boolean; }; @@ -111,11 +110,11 @@ export type SpringOptions = { precision?: number; }; -export type SpringTargetPrimitive = number | Date; export type SpringTarget = - | SpringTargetPrimitive - | { [key: string]: SpringTargetPrimitive | SpringTarget } - | SpringTargetPrimitive[] + | number + | Date + | { [key: string]: number | Date | SpringTarget } + | (number | Date)[] | SpringTarget[]; /** @@ -152,41 +151,29 @@ export function createSpring( initialValue: T, options: SpringOptions = {}, ): [Accessor>, SpringSetter>] { - const [springValue, setSpringValue] = createSignal(initialValue); + const [springValue, setSpringValue] = createSignal(initialValue); const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; - let lastTime = 0; + let last_time = 0; let task: Task | null = null; let current_token: object | undefined = undefined; - let lastValue: T = initialValue; - let targetValue: T | undefined; + let current_value = initialValue + let last_value = initialValue; + let target_value = initialValue; let inv_mass = 1; let inv_mass_recovery_rate = 0; - let cancelTask = false; + let cancel_task = false; - /** - * Gets `newValue` from the SpringSetter's first argument. - */ - function getNewValue(newValue: T | ((prev: T) => T)) { - if (typeof newValue === "function") { - return newValue(lastValue); - } - - return newValue; - } - - const set: SpringSetter = untrack(() => (newValue, opts = {}) => { - targetValue = getNewValue(newValue); + const set: SpringSetter = (param, opts = {}) => { + target_value = typeof param === "function" ? param(current_value) : param; const token = current_token ?? {}; current_token = token; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (springValue() == null || opts.hard || (stiffness >= 1 && damping >= 1)) { - cancelTask = true; - lastTime = raf.now(); - lastValue = getNewValue(newValue); - setSpringValue(_ => getNewValue(newValue)); + if (current_value == null || opts.hard || (stiffness >= 1 && damping >= 1)) { + cancel_task = true; + last_time = raf.now(); + setSpringValue(_ => current_value = last_value = target_value); return Promise.resolve(); } else if (opts.soft) { const rate = opts.soft === true ? 0.5 : +opts.soft; @@ -194,33 +181,27 @@ export function createSpring( inv_mass = 0; // Infinite mass, unaffected by spring forces. } if (!task) { - lastTime = raf.now(); - cancelTask = false; + last_time = raf.now(); + cancel_task = false; const _loop = loop(now => { - if (cancelTask) { - cancelTask = false; + if (cancel_task) { + cancel_task = false; task = null; return false; } inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1); - const ctx: TickContext = { + const ctx: TickContext = { inv_mass: inv_mass, - opts: { - set: set, - damping: damping, - precision: precision, - stiffness: stiffness, - }, settled: true, - dt: ((now - lastTime) * 60) / 1000, + dt: ((now - last_time) * 60) / 1000, }; - const next_value = tick_spring(ctx, lastValue, springValue(), targetValue!); - lastTime = now; - lastValue = springValue(); - setSpringValue(_ => next_value); + let new_value = tick_spring(ctx, last_value, current_value, target_value) + last_time = now; + last_value = current_value; + setSpringValue(current_value = new_value); if (ctx.settled) { task = null; } @@ -235,53 +216,42 @@ export function createSpring( if (token === current_token) fulfil(); }); }); - }); + }; - const tick_spring = ( - ctx: TickContext, + const tick_spring = ( + ctx: TickContext, last_value: T, current_value: T, target_value: T, - ): T => { + ): any => { if (typeof current_value === "number" || is_date(current_value)) { - // @ts-ignore - const delta = target_value - current_value; - // @ts-ignore - const velocity = (current_value - last_value) / (ctx.dt || 1 / 60); // guard div by 0 - const spring = ctx.opts.stiffness! * delta; - const damper = ctx.opts.damping! * velocity; + const delta = +target_value - +current_value; + const velocity = (+current_value - +last_value) / (ctx.dt || 1 / 60); // guard div by 0 + const spring = stiffness * delta; + const damper = damping * velocity; const acceleration = (spring - damper) * ctx.inv_mass; const d = (velocity + acceleration) * ctx.dt; - if (Math.abs(d) < ctx.opts.precision! && Math.abs(delta) < ctx.opts.precision!) { + if (Math.abs(d) < precision && Math.abs(delta) < precision) { return target_value; // settled - } else { - ctx.settled = false; // signal loop to keep ticking - // @ts-ignore - return is_date(current_value) ? new Date(current_value.getTime() + d) : current_value + d; } - } else if (Array.isArray(current_value)) { - // @ts-ignore + ctx.settled = false; // signal loop to keep ticking + return typeof current_value === "number" ? current_value + d : new Date(+current_value + d); + } + if (Array.isArray(current_value)) { return current_value.map((_, i) => - // @ts-ignore + // @ts-expect-error tick_spring(ctx, last_value[i], current_value[i], target_value[i]), ); - } else if (typeof current_value === "object") { - const next_value = {}; + } + if (typeof current_value === "object") { + const next_value = {...current_value}; for (const k in current_value) { - // @ts-ignore - next_value[k] = tick_spring( - ctx, - // @ts-ignore - last_value[k], - current_value[k], - target_value[k], - ); + // @ts-expect-error + next_value[k] = tick_spring(ctx, last_value[k], current_value[k], target_value[k]); } - // @ts-ignore return next_value; - } else { - throw new Error(`Cannot spring ${typeof current_value} values`); } + throw new Error(`Cannot spring ${typeof current_value} values`); }; return [ diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index f0f687cba..3be93fffa 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,4 +1,4 @@ -import { createRoot, createSignal } from "solid-js"; +import { createEffect, createRoot, createSignal } from "solid-js"; import { describe, expect, it, vi, afterAll, beforeAll, beforeEach } from "vitest"; import { createDerivedSpring, createSpring } from "../src/index.js"; @@ -119,6 +119,34 @@ describe("createSpring", () => { dispose(); }); + it("Setter does not subscribe to self", () => { + let runs = 0 + const [signal, setSignal] = createSignal(0) + + const [setSpring, dispose] = createRoot(dispose => { + const [, setSpring] = createSpring(0) + + createEffect(() => { + runs++ + setSpring(p => { + signal() // this one should be tracked + return p+1 + }, { hard: true }) + }) + + return [setSpring, dispose] + }); + expect(runs).toBe(1) + + setSpring(p => p+1, { hard: true }) + expect(runs).toBe(1) + + setSignal(1) + expect(runs).toBe(2) + + dispose(); + }); + it("instantly updates `number` when set with hard using a function as an argument.", () => { const start = 0; const end = 50; From eeff964b5a28a234ed60c9ad8d335b8892e2a0dc Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 19 Sep 2024 23:51:25 +0200 Subject: [PATCH 11/14] Inline tasks abstraction into createSpring primitive --- packages/spring/package.json | 2 +- packages/spring/src/index.ts | 244 ++++++++++------------------- packages/spring/test/index.test.ts | 91 ++++++----- 3 files changed, 140 insertions(+), 197 deletions(-) diff --git a/packages/spring/package.json b/packages/spring/package.json index 1d735ec16..b7ddf92f8 100644 --- a/packages/spring/package.json +++ b/packages/spring/package.json @@ -3,7 +3,7 @@ "version": "0.0.100", "description": "Primitive that creates spring physics functions.", "author": "Carlo Taleon ", - "contributors": [], + "contributors": ["Damian Tarnawski "], "license": "MIT", "homepage": "https://primitives.solidjs.community/package/spring", "repository": { diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index b84ad8168..e2bcdc161 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -1,89 +1,16 @@ +import { Accessor, createEffect, createSignal, onCleanup } from "solid-js"; import { isServer } from "solid-js/web"; -// =========================================================================== -// Internals -// =========================================================================== - -// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/types.d.ts - -type Task = { - abort(): void; - promise: Promise; -}; - -type TickContext = { - inv_mass: number; - dt: number; - settled: boolean; -}; - -type TaskCallback = (now: number) => boolean | void; - -type TaskEntry = { c: TaskCallback; f: () => void }; - -// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/timing.js - -const raf: { - /** Alias for `requestAnimationFrame`, exposed in such a way that we can override in tests */ - tick: (callback: (time: DOMHighResTimeStamp) => void) => any; - /** Alias for `performance.now()`, exposed in such a way that we can override in tests */ - now: () => number; - /** A set of tasks that will run to completion, unless aborted */ - tasks: Set; -} = { - tick: (_: any) => (isServer ? () => {} : requestAnimationFrame(_)), // SSR-safe RAF function. - now: () => performance.now(), // Getter for now() using performance in browser and Date in server. Although both are available in node and browser. - tasks: new Set(), -}; - // https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js function is_date(obj: any): obj is Date { return Object.prototype.toString.call(obj) === "[object Date]"; } -// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/internal/client/loop.js - -function run_tasks(now: number) { - raf.tasks.forEach(task => { - if (!task.c(now)) { - raf.tasks.delete(task); - task.f(); - } - }); - - if (raf.tasks.size !== 0) { - raf.tick(run_tasks); - } -} - -/** - * Creates a new task that runs on each raf frame - * until it returns a falsy value or is aborted - */ -function loop(callback: TaskCallback): Task { - let task: TaskEntry; - - if (raf.tasks.size === 0) { - raf.tick(run_tasks); - } - - return { - promise: new Promise((fulfill: any) => { - raf.tasks.add((task = { c: callback, f: fulfill })); - }), - abort() { - raf.tasks.delete(task); - }, - }; -} - // =========================================================================== // createSpring hook // =========================================================================== -import { Accessor, createEffect, createSignal, untrack } from "solid-js"; - export type SpringOptions = { /** * Stiffness of the spring. Higher values will create more sudden movement. @@ -114,8 +41,8 @@ export type SpringTarget = | number | Date | { [key: string]: number | Date | SpringTarget } - | (number | Date)[] - | SpringTarget[]; + | readonly (number | Date)[] + | readonly SpringTarget[]; /** * "Widen" Utility Type so that number types are not converted to @@ -125,9 +52,10 @@ export type SpringTarget = */ export type WidenSpringTarget = T extends number ? number : T; +export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number } export type SpringSetter = ( newValue: T | ((prev: T) => T), - opts?: { hard?: boolean; soft?: boolean | number }, + opts?: SpringSetterOptions, ) => Promise; /** @@ -151,113 +79,111 @@ export function createSpring( initialValue: T, options: SpringOptions = {}, ): [Accessor>, SpringSetter>] { - const [springValue, setSpringValue] = createSignal(initialValue); + const [signal, setSignal] = createSignal(initialValue); const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options; - let last_time = 0; - let task: Task | null = null; - let current_token: object | undefined = undefined; - let current_value = initialValue - let last_value = initialValue; - let target_value = initialValue; + if (isServer) { + return [signal as any, ((param: any, opts: SpringSetterOptions = {}) => { + if (opts.hard || signal() == null || (stiffness >= 1 && damping >= 1)) { + setSignal(param); + return Promise.resolve(); + } + return new Promise(() => {}); + }) as any] + } + + let value_current = initialValue; + let value_last = initialValue; + let value_target = initialValue; let inv_mass = 1; let inv_mass_recovery_rate = 0; - let cancel_task = false; + let raf_id = 0 + let settled = true + let time_last = 0; + let time_delta = 0 + let resolve = () => {} + + const cleanup = onCleanup(() => { + cancelAnimationFrame(raf_id) + raf_id = 0 + resolve() + }) + + const frame: FrameRequestCallback = time => { + time_delta = (time - time_last) * 60 / 1000 + time_last = time + + inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1) + settled = true + + let new_value = tick(value_last, value_current, value_target) + value_last = value_current + setSignal(value_current = new_value) + + if (settled) { + cleanup() + } else { + raf_id = requestAnimationFrame(frame) + } + } const set: SpringSetter = (param, opts = {}) => { - target_value = typeof param === "function" ? param(current_value) : param; - - const token = current_token ?? {}; - current_token = token; + value_target = typeof param === "function" ? param(value_current) : param; - if (current_value == null || opts.hard || (stiffness >= 1 && damping >= 1)) { - cancel_task = true; - last_time = raf.now(); - setSpringValue(_ => current_value = last_value = target_value); + if (opts.hard || (stiffness >= 1 && damping >= 1)) { + cleanup() + setSignal(_ => value_current = value_last = value_target); return Promise.resolve(); - } else if (opts.soft) { - const rate = opts.soft === true ? 0.5 : +opts.soft; - inv_mass_recovery_rate = 1 / (rate * 60); - inv_mass = 0; // Infinite mass, unaffected by spring forces. } - if (!task) { - last_time = raf.now(); - cancel_task = false; - const _loop = loop(now => { - if (cancel_task) { - cancel_task = false; - task = null; - return false; - } - - inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1); - - const ctx: TickContext = { - inv_mass: inv_mass, - settled: true, - dt: ((now - last_time) * 60) / 1000, - }; - let new_value = tick_spring(ctx, last_value, current_value, target_value) - last_time = now; - last_value = current_value; - setSpringValue(current_value = new_value); - if (ctx.settled) { - task = null; - } - - return !ctx.settled; - }); + if (opts.soft) { + inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30); + inv_mass = 0; // Infinite mass, unaffected by spring forces. + } - task = _loop; + if (raf_id === 0) { + time_last = performance.now() + raf_id = requestAnimationFrame(frame) } - return new Promise(fulfil => { - task?.promise.then(() => { - if (token === current_token) fulfil(); - }); - }); + + return new Promise(r => resolve = r); }; - const tick_spring = ( - ctx: TickContext, - last_value: T, - current_value: T, - target_value: T, - ): any => { - if (typeof current_value === "number" || is_date(current_value)) { - const delta = +target_value - +current_value; - const velocity = (+current_value - +last_value) / (ctx.dt || 1 / 60); // guard div by 0 + const tick = (last: T, current: T, target: T): any => { + if (typeof current === "number" || is_date(current)) { + const delta = +target - +current; + const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0 const spring = stiffness * delta; const damper = damping * velocity; - const acceleration = (spring - damper) * ctx.inv_mass; - const d = (velocity + acceleration) * ctx.dt; + const acceleration = (spring - damper) * inv_mass; + const d = (velocity + acceleration) * time_delta; + if (Math.abs(d) < precision && Math.abs(delta) < precision) { - return target_value; // settled + return target; // settled } - ctx.settled = false; // signal loop to keep ticking - return typeof current_value === "number" ? current_value + d : new Date(+current_value + d); + + settled = false; // signal loop to keep ticking + return typeof current === "number" ? current + d : new Date(+current + d); } - if (Array.isArray(current_value)) { - return current_value.map((_, i) => - // @ts-expect-error - tick_spring(ctx, last_value[i], current_value[i], target_value[i]), - ); + + if (Array.isArray(current)) { + // @ts-expect-error + return current.map((_, i) => tick(last[i], current[i], target[i])); } - if (typeof current_value === "object") { - const next_value = {...current_value}; - for (const k in current_value) { + + if (typeof current === "object") { + const next = {...current}; + for (const k in current) { // @ts-expect-error - next_value[k] = tick_spring(ctx, last_value[k], current_value[k], target_value[k]); + next[k] = tick(last[k], current[k], target[k]); } - return next_value; + return next; } - throw new Error(`Cannot spring ${typeof current_value} values`); + + throw new Error(`Cannot spring ${typeof current} values`); }; - return [ - springValue as Accessor>, - set as unknown as SpringSetter>, - ]; + return [signal as any, set as any]; } // =========================================================================== diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 3be93fffa..71f877879 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -48,18 +48,31 @@ describe("createSpring", () => { dispose(); }); - it("updates toward target", () => { - const [[spring, setSpring], dispose] = createRoot(d => [createSpring(0), d]); + it("Setter does not subscribe to self", () => { + let runs = 0 + const [signal, setSignal] = createSignal(0) + + const [setSpring, dispose] = createRoot(dispose => { + const [, setSpring] = createSpring(0) - expect(spring()).toBe(0); - setSpring(50); - expect(spring()).toBe(0); + createEffect(() => { + runs++ + setSpring(p => { + signal() // this one should be tracked + return p+1 + }, { hard: true }) + }) - _progress_time(300) + return [setSpring, dispose] + }); + expect(runs).toBe(1) + + setSpring(p => p+1, { hard: true }) + expect(runs).toBe(1) + + setSignal(1) + expect(runs).toBe(2) - // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) - expect(spring()).not.toBe(50); - expect(spring()).toBeGreaterThan(50 / 2); dispose(); }); @@ -119,34 +132,6 @@ describe("createSpring", () => { dispose(); }); - it("Setter does not subscribe to self", () => { - let runs = 0 - const [signal, setSignal] = createSignal(0) - - const [setSpring, dispose] = createRoot(dispose => { - const [, setSpring] = createSpring(0) - - createEffect(() => { - runs++ - setSpring(p => { - signal() // this one should be tracked - return p+1 - }, { hard: true }) - }) - - return [setSpring, dispose] - }); - expect(runs).toBe(1) - - setSpring(p => p+1, { hard: true }) - expect(runs).toBe(1) - - setSignal(1) - expect(runs).toBe(2) - - dispose(); - }); - it("instantly updates `number` when set with hard using a function as an argument.", () => { const start = 0; const end = 50; @@ -174,6 +159,38 @@ describe("createSpring", () => { dispose(); }); + + it("updates toward target", () => { + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(0), d]); + + expect(spring()).toBe(0); + setSpring(50); + expect(spring()).toBe(0); + + _progress_time(300) + + // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly) + expect(spring()).not.toBe(50); + expect(spring()).toBeGreaterThan(50 / 2); + dispose(); + }); + + it("updates array of objects toward target", () => { + const start = [{foo: 1}, {foo: 2}, {foo: 3}]; + const end = [{foo: 20}, {foo: 15}, {foo: 20}]; + + const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]); + + expect(spring()).toMatchObject(start); + setSpring(end); + + _progress_time(300) + for (let i = 0; i < start.length; i++) { + expect(spring()[i]!.foo).toBeGreaterThan(end[i]!.foo/2); + } + + dispose(); + }); }); describe("createDerivedSpring", () => { From 53b08a5fc03e9200bd7a4543bc1de273f5a7ba50 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Tue, 24 Sep 2024 15:43:48 +0200 Subject: [PATCH 12/14] Ensure delta time cannot be less than zero --- packages/spring/src/index.ts | 2 +- packages/spring/test/index.test.ts | 30 ++++++++++-------------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index e2bcdc161..4efbe31d0 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -110,7 +110,7 @@ export function createSpring( }) const frame: FrameRequestCallback = time => { - time_delta = (time - time_last) * 60 / 1000 + time_delta = Math.max(0, time - time_last) * 60 / 1000 time_last = time inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1) diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts index 71f877879..291e4c396 100644 --- a/packages/spring/test/index.test.ts +++ b/packages/spring/test/index.test.ts @@ -1,21 +1,26 @@ import { createEffect, createRoot, createSignal } from "solid-js"; -import { describe, expect, it, vi, afterAll, beforeAll, beforeEach } from "vitest"; +import { describe, expect, it, vi, afterAll, } from "vitest"; import { createDerivedSpring, createSpring } from "../src/index.js"; +let _time = 0 let _raf_last_id = 0; let _raf_callbacks_old = new Map(); let _raf_callbacks_new = new Map(); function _progress_time(by: number) { - const start = performance.now(); - vi.advanceTimersByTime(by); - + _time += by _raf_callbacks_old = _raf_callbacks_new; _raf_callbacks_new = new Map(); - _raf_callbacks_old.forEach(c => c(start + by)); + _raf_callbacks_old.forEach(c => c(_time)); _raf_callbacks_old.clear(); } +let _now = performance.now +performance.now = () => _time +afterAll(() => { + performance.now = _now +}) + vi.stubGlobal("requestAnimationFrame", function (callback: FrameRequestCallback): number { const id = _raf_last_id++; _raf_callbacks_new.set(id, callback); @@ -25,21 +30,6 @@ vi.stubGlobal("cancelAnimationFrame", function (id: number): void { _raf_callbacks_new.delete(id); }); -beforeAll(() => { - vi.useFakeTimers(); -}); - -beforeEach(() => { - vi.clearAllTimers(); -}); - -afterAll(() => { - vi.useRealTimers(); - _raf_callbacks_old.clear(); - _raf_callbacks_new.clear(); - _raf_last_id = 0; -}); - describe("createSpring", () => { it("returns values", () => { From fd412283fcbf2271acb28df19b8e21747ab5ebf3 Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 26 Sep 2024 18:34:37 +0200 Subject: [PATCH 13/14] Improve guard against zero delta --- packages/spring/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts index 4efbe31d0..36a53fd53 100644 --- a/packages/spring/src/index.ts +++ b/packages/spring/src/index.ts @@ -110,7 +110,7 @@ export function createSpring( }) const frame: FrameRequestCallback = time => { - time_delta = Math.max(0, time - time_last) * 60 / 1000 + time_delta = Math.max(1 / 60, (time - time_last) * 60 / 1000) // guard against d<=0 time_last = time inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1) @@ -152,7 +152,7 @@ export function createSpring( const tick = (last: T, current: T, target: T): any => { if (typeof current === "number" || is_date(current)) { const delta = +target - +current; - const velocity = (+current - +last) / (time_delta || 1 / 60); // guard div by 0 + const velocity = (+current - +last) / time_delta; const spring = stiffness * delta; const damper = damping * velocity; const acceleration = (spring - damper) * inv_mass; From 0222ae166850e189ea2e8b7b354f19e6152bc66c Mon Sep 17 00:00:00 2001 From: Damian Tarnawski Date: Thu, 26 Sep 2024 18:52:54 +0200 Subject: [PATCH 14/14] Add demo page to spring --- packages/spring/README.md | 4 +- packages/spring/dev/index.tsx | 121 ++++++++++++++++++++++++++++++++++ packages/spring/package.json | 2 +- 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 packages/spring/dev/index.tsx diff --git a/packages/spring/README.md b/packages/spring/README.md index 0e6dd321d..065d2a428 100644 --- a/packages/spring/README.md +++ b/packages/spring/README.md @@ -54,7 +54,9 @@ const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 }); ## Demo -- [CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr) +- **[Playground](https://primitives.solidjs.community/playground/spring)** - [source code](https://github.com/solidjs-community/solid-primitives/blob/main/packages/spring/dev/index.tsx) + +- **[CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr)** ## Changelog diff --git a/packages/spring/dev/index.tsx b/packages/spring/dev/index.tsx new file mode 100644 index 000000000..d59259571 --- /dev/null +++ b/packages/spring/dev/index.tsx @@ -0,0 +1,121 @@ +import { createSpring } from "../src/index.js" + +export default function App() { + const [progress, setProgress] = createSpring(0); + const [radialProgress, setRadialProgress] = createSpring(0, { + stiffness: 0.1, damping: 0.3 + }); + const [xy, setXY] = createSpring({ x: 50, y: 50 }, { stiffness: 0.1, damping: 0.3 }); + const [date, setDate] = createSpring(new Date()); + + function toggleProgress() { + if (progress() === 0) setProgress(1); + else setProgress(0); + } + function toggleRadialProgress() { + if (radialProgress() === 0) setRadialProgress(1); + else setRadialProgress(0); + } + let d = false + function toggleXY() { + if (d = !d) setXY({ x: 200, y: 200 }); + else setXY({ x: 50, y: 50 }); + } + function toggleDate() { + if (date().getDate() === new Date("2024-12-01").getDate()) setDate(new Date("2024-04-14")); + else setDate(new Date("2024-12-01")); + } + + return <> + +
+
+ + + + +
+ + {/* Progress */} +
+ + +

{(progress() * 100).toFixed(0)}%

+
+ + {/* Radial progress */} +
+ + + + + {(radialProgress() * 100).toFixed(0)}% +
+ + {/* XY */} +
+ {xy().x.toFixed(0)}x{xy().y.toFixed(0)} +
+ + {/* Date */} +
{date()+""}
+
+ +}; diff --git a/packages/spring/package.json b/packages/spring/package.json index b7ddf92f8..cfb0e5ff5 100644 --- a/packages/spring/package.json +++ b/packages/spring/package.json @@ -1,6 +1,6 @@ { "name": "@solid-primitives/spring", - "version": "0.0.100", + "version": "0.0.1", "description": "Primitive that creates spring physics functions.", "author": "Carlo Taleon ", "contributors": ["Damian Tarnawski "],