diff --git a/.babelrc b/.babelrc
deleted file mode 100644
index 18e32e5b9d..0000000000
--- a/.babelrc
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "presets": [
- [
- "@babel/preset-env",
- {
- "loose": true,
- "modules": false,
- "targets": ">1%, not dead, not ie 11, not op_mini all"
- }
- ],
- "@babel/preset-react",
- "@babel/preset-typescript"
- ],
- "plugins": [
- ["@babel/proposal-class-properties", { "loose": true }],
- ["@babel/plugin-proposal-object-rest-spread", { "loose": true }],
- ["transform-react-remove-prop-types", { "removeImport": true }]
- ],
- "env": {
- "test": {
- "plugins": ["@babel/transform-modules-commonjs"]
- }
- }
-}
diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json
new file mode 100644
index 0000000000..8692f3a107
--- /dev/null
+++ b/.codesandbox/ci.json
@@ -0,0 +1,5 @@
+{
+ "packages": ["packages/*", "targets/*"],
+ "sandboxes": ["/demo/src/sandboxes/card", "/demo/src/sandboxes/gooBlobs"],
+ "node": "14"
+}
diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md
index ef23df4133..cd6caea346 100644
--- a/.github/ISSUE_TEMPLATE/question.md
+++ b/.github/ISSUE_TEMPLATE/question.md
@@ -6,4 +6,4 @@ about: Ask the maintainers (as a last resort)
## 🤓 Question
-(You _must_ search the issues before asking your question. Please consider asking in [the official Spectrum community](https://spectrum.chat/react-spring) and/or [Stack Overflow](https://stackoverflow.com) first.)
+(You _must_ search the issues before asking your question. Please consider asking in [the Discussions tab](https://github.com/pmndrs/react-spring/discussions) and/or [Stack Overflow](https://stackoverflow.com) first.)
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000000..4f04256beb
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,23 @@
+name: release
+on:
+ push: {}
+ pull_request: {}
+jobs:
+ main:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.6.0
+ with:
+ access_token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Checkout repo
+ uses: actions/checkout@v2
+ - name: Setup node
+ uses: actions/setup-node@v2
+ with:
+ node-version: '14.x'
+ - name: Install deps
+ # this runs a build script so there is no dedicated build
+ run: yarn install
+ - name: Run tests
+ run: yarn test:ts && yarn test
diff --git a/.gitignore b/.gitignore
index 68d5b34e39..421d8e6afc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,13 +1,16 @@
+dist/
+.bic_cache
+.rpt2_cache/
node_modules/
coverage/
-dist/
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
.DS_Store
-.vscode
-.docz/
package-lock.json
-coverage/
+report.*.json
.idea
+*.log
+/docs/
+/examples/
diff --git a/.meta b/.meta
new file mode 100644
index 0000000000..c64d50981c
--- /dev/null
+++ b/.meta
@@ -0,0 +1,6 @@
+{
+ "projects": {
+ "docs": "https://github.com/react-spring/react-spring.io.git",
+ "examples": "https://github.com/react-spring/react-spring-examples.git"
+ }
+}
\ No newline at end of file
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000000..397b4a7624
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1 @@
+*.log
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000000..431bd54b58
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "arrowParens": "avoid",
+ "jsxBracketSameLine": true,
+ "printWidth": 80,
+ "semi": false,
+ "singleQuote": true,
+ "tabWidth": 2,
+ "trailingComma": "es5"
+}
diff --git a/.size-snapshot.json b/.size-snapshot.json
deleted file mode 100644
index 3e4bc5ebaa..0000000000
--- a/.size-snapshot.json
+++ /dev/null
@@ -1,261 +0,0 @@
-{
- "dist/addons.js": {
- "bundled": 9443,
- "minified": 5181,
- "gzipped": 1800,
- "treeshaked": {
- "rollup": {
- "code": 4695,
- "import_statements": 279
- },
- "webpack": {
- "code": 5869
- }
- }
- },
- "dist/addons.umd.js": {
- "bundled": 11277,
- "minified": 5334,
- "gzipped": 1999
- },
- "dist/web.js": {
- "bundled": 63554,
- "minified": 30170,
- "gzipped": 10988,
- "treeshaked": {
- "rollup": {
- "code": 11571,
- "import_statements": 242
- },
- "webpack": {
- "code": 12733
- }
- }
- },
- "dist/web.umd.js": {
- "bundled": 82763,
- "minified": 32482,
- "gzipped": 11641
- },
- "dist/native.js": {
- "bundled": 60686,
- "minified": 28249,
- "gzipped": 10010,
- "treeshaked": {
- "rollup": {
- "code": 8256,
- "import_statements": 180
- },
- "webpack": {
- "code": 10881
- }
- }
- },
- "dist/universal.js": {
- "bundled": 48123,
- "minified": 21539,
- "gzipped": 7195,
- "treeshaked": {
- "rollup": {
- "code": 1235,
- "import_statements": 128
- },
- "webpack": {
- "code": 4733
- }
- }
- },
- "dist/konva.js": {
- "bundled": 59176,
- "minified": 27394,
- "gzipped": 9921,
- "treeshaked": {
- "rollup": {
- "code": 9090,
- "import_statements": 242
- },
- "webpack": {
- "code": 10246
- }
- }
- },
- "dist/hooks.js": {
- "bundled": 71628,
- "minified": 33389,
- "gzipped": 11200,
- "treeshaked": {
- "rollup": {
- "code": 12806,
- "import_statements": 362
- },
- "webpack": {
- "code": 14060
- }
- }
- },
- "dist/hooks.umd.js": {
- "bundled": 89934,
- "minified": 35216,
- "gzipped": 12303
- },
- "dist/native-hooks.js": {
- "bundled": 73025,
- "minified": 34369,
- "gzipped": 10627,
- "treeshaked": {
- "rollup": {
- "code": 10032,
- "import_statements": 345
- },
- "webpack": {
- "code": 25161
- }
- }
- },
- "dist/web-hooks.js": {
- "bundled": 71543,
- "minified": 33114,
- "gzipped": 11122,
- "treeshaked": {
- "rollup": {
- "code": 12742,
- "import_statements": 362
- },
- "webpack": {
- "code": 13996
- }
- }
- },
- "dist/renderprops.js": {
- "bundled": 66797,
- "minified": 32950,
- "gzipped": 11394
- },
- "dist/renderprops-addons.js": {
- "bundled": 8318,
- "minified": 5069,
- "gzipped": 1763
- },
- "dist/renderprops-addons.umd.js": {
- "bundled": 11301,
- "minified": 5358,
- "gzipped": 2008
- },
- "dist/renderprops-native.js": {
- "bundled": 61366,
- "minified": 29741,
- "gzipped": 9942
- },
- "dist/renderprops-universal.js": {
- "bundled": 49087,
- "minified": 23177,
- "gzipped": 7220
- },
- "dist/renderprops-konva.js": {
- "bundled": 60176,
- "minified": 29110,
- "gzipped": 9928
- },
- "dist/web.cjs.js": {
- "bundled": 71882,
- "minified": 33885,
- "gzipped": 11553
- },
- "dist/native.cjs.js": {
- "bundled": 69495,
- "minified": 32150,
- "gzipped": 10560
- },
- "dist/renderprops.cjs.js": {
- "bundled": 77392,
- "minified": 36683,
- "gzipped": 11921
- },
- "dist/renderprops-addons.cjs.js": {
- "bundled": 9833,
- "minified": 5495,
- "gzipped": 1892
- },
- "dist/renderprops-native.cjs.js": {
- "bundled": 72258,
- "minified": 33567,
- "gzipped": 10465
- },
- "dist/renderprops-universal.cjs.js": {
- "bundled": 59263,
- "minified": 26748,
- "gzipped": 7733
- },
- "dist/renderprops-konva.cjs.js": {
- "bundled": 70422,
- "minified": 32717,
- "gzipped": 10442
- },
- "dist/universal.cjs.js": {
- "bundled": 56122,
- "minified": 25121,
- "gzipped": 7750
- },
- "dist/test.js": {
- "bundled": 32365,
- "minified": 14259,
- "gzipped": 4783,
- "treeshaked": {
- "rollup": {
- "code": 523,
- "import_statements": 128
- },
- "webpack": {
- "code": 2095
- }
- }
- },
- "dist/test.cjs.js": {
- "bundled": 40723,
- "minified": 17987,
- "gzipped": 5405
- },
- "dist/konva.cjs.js": {
- "bundled": 67216,
- "minified": 31007,
- "gzipped": 10463
- },
- "dist/three.js": {
- "bundled": 59525,
- "minified": 27361,
- "gzipped": 9830,
- "treeshaked": {
- "rollup": {
- "code": 10499,
- "import_statements": 344
- },
- "webpack": {
- "code": 11711
- }
- }
- },
- "dist/three.cjs.js": {
- "bundled": 67628,
- "minified": 31022,
- "gzipped": 10369
- },
- "dist/zdog.js": {
- "bundled": 60089,
- "minified": 27582,
- "gzipped": 9886,
- "treeshaked": {
- "rollup": {
- "code": 10633,
- "import_statements": 354
- },
- "webpack": {
- "code": 11762
- }
- }
- },
- "dist/zdog.cjs.js": {
- "bundled": 68132,
- "minified": 31183,
- "gzipped": 10438
- }
-}
diff --git a/.travis.yml b/.travis.yml
index 98d32a7e7e..d5ed9bc783 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,3 +1,6 @@
language: node_js
node_js:
- - stable
\ No newline at end of file
+ - stable
+script:
+ - yarn test:ts
+ - yarn test
diff --git a/.vscode/react-spring.code-workspace b/.vscode/react-spring.code-workspace
new file mode 100644
index 0000000000..f3952db213
--- /dev/null
+++ b/.vscode/react-spring.code-workspace
@@ -0,0 +1,27 @@
+{
+ "folders": [
+ {
+ "name": "targets",
+ "path": "../targets"
+ },
+ {
+ "name": "packages",
+ "path": "../packages"
+ },
+ {
+ "name": "examples",
+ "path": "../examples"
+ },
+ {
+ "name": "docs",
+ "path": "../docs"
+ }
+ ],
+ "settings": {
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "files.exclude": {
+ "**/.bic_cache": true,
+ "**/.rpt2_cache": true
+ }
+ }
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000000..b008494a2f
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,7 @@
+{
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "files.exclude": {
+ "**/.bic_cache": true,
+ "**/.rpt2_cache": true
+ }
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000..6fac19dcea
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,82 @@
+# How to Contribute
+
+1. Clone this repository:
+
+```sh
+git clone https://github.com/react-spring/react-spring -b v9
+cd react-spring
+```
+
+2. Install `yarn` (https://yarnpkg.com/en/docs/install)
+
+3. Bootstrap the packages:
+
+```sh
+yarn
+
+# Clone the docs and examples (optional)
+yarn meta git update
+```
+
+4. Link the packages:
+
+```sh
+# Use the .js bundles
+yarn lerna exec 'cd dist && yarn link || exit 0'
+
+# Or use the uncompiled .ts packages
+yarn lerna exec 'yarn link'
+```
+
+5. Link `react-spring` to your project:
+
+```sh
+cd ~/my-project
+yarn link react-spring
+```
+
+6. Let's get cooking! 👨🏻🍳🥓
+
+## Guidelines
+
+Be sure your commit messages follow this specification: https://www.conventionalcommits.org/en/v1.0.0-beta.4/
+
+### Duplicate `react` errors
+
+React 16.8+ has global state to support its "hooks" feature, so you need to ensure only one copy of `react` exists in your program. Otherwise, you'll most likely see [this error](https://reactjs.org/warnings/invalid-hook-call-warning.html). Please try the following solutions, and let us know if it still doesn't work for you.
+
+- **For `create-react-app` users:** Follow this guide: https://github.com/facebook/react/issues/13991#issuecomment-496383268
+
+- **For `webpack` users:** Add an alias to `webpack.config.js` like this:
+
+ ```js
+ alias: {
+ react: path.resolve('node_modules/react'),
+ }
+ ```
+
+- **For `gatsby` users:** Install `gatsby-plugin-alias-imports` and add this to your `gatsby-config.js` module:
+ ```js
+ {
+ resolve: `gatsby-plugin-alias-imports`,
+ options: {
+ alias: {
+ react: path.resolve('node_modules/react'),
+ },
+ },
+ },
+ ```
+
+# Publishing
+
+To publish a new version:
+
+```
+yarn release
+```
+
+To publish a **canary** version:
+
+```
+yarn release --canary
+```
diff --git a/LICENSE b/LICENSE
index cf07ab9d07..a926771a85 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2018 Paul Henschel
+Copyright (c) 2018-present Paul Henschel, react-spring, all contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/readme.md b/README.md
similarity index 83%
rename from readme.md
rename to README.md
index 4cf9259538..0fb6fe1310 100644
--- a/readme.md
+++ b/README.md
@@ -10,7 +10,7 @@
This library represents a modern approach to animation. It is very much inspired by Christopher Chedeau's [animated](https://github.com/animatedjs/animated) and Cheng Lou's [react-motion](https://github.com/chenglou/react-motion). It inherits animated's powerful interpolations and performance, as well as react-motion's ease of use. But while animated is mostly imperative and react-motion mostly declarative, react-spring bridges both. You will be surprised how easy static data is cast into motion with small, explicit utility functions that don't necessarily affect how you form your views.
-[](https://badge.fury.io/js/react-spring) [](https://spectrum.chat/react-spring) [](https://discord.gg/ZZjjNvJ) [](#backers) [](#sponsors)
+[](https://github.com/pmndrs/react-spring/actions/workflows/main.yml) [](https://badge.fury.io/js/react-spring) [](https://discord.gg/ZZjjNvJ) [](#backers) [](#sponsors)
### Installation
@@ -54,7 +54,7 @@ Springs change that, animation becomes easy and approachable, everything you do
-And [many others](https://github.com/drcmda/react-spring/network/dependents) ...
+And [many others](https://github.com/react-spring/react-spring/network/dependents) ...
## Funding
@@ -93,6 +93,6 @@ Thank you to all our backers! 🙏
This project exists thanks to all the people who contribute.
-
+
diff --git a/demo/.gitignore b/demo/.gitignore
new file mode 100644
index 0000000000..d451ff16c1
--- /dev/null
+++ b/demo/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
diff --git a/demo/favicon.svg b/demo/favicon.svg
new file mode 100644
index 0000000000..1c0427d032
--- /dev/null
+++ b/demo/favicon.svg
@@ -0,0 +1,6 @@
+
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000000..9949a0bc7d
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ react-spring example
+
+
+
+
+
+
diff --git a/demo/package.json b/demo/package.json
new file mode 100644
index 0000000000..d288d7f9ea
--- /dev/null
+++ b/demo/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "demo-react-spring-sandbox",
+ "version": "1.0.0",
+ "description": "a sandbox for react-spring (probably will be replaced with something in the future)",
+ "main": "null",
+ "author": "Josh Ellis",
+ "license": "MIT",
+ "private": false,
+ "scripts": {
+ "dev": "vite",
+ "serve": "vite preview"
+ },
+ "dependencies": {
+ "react": "^17.0.1",
+ "react-dom": "^17.0.1",
+ "wouter": "^2.7.4"
+ },
+ "devDependencies": {
+ "@types/react": "^17.0.3",
+ "@types/react-dom": "^17.0.2",
+ "@vitejs/plugin-react-refresh": "^1.3.1",
+ "typescript": "^4.2.3",
+ "vite": "^2.1.2"
+ },
+ "bic": false
+}
diff --git a/demo/src/App.tsx b/demo/src/App.tsx
new file mode 100644
index 0000000000..5ebea413ef
--- /dev/null
+++ b/demo/src/App.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react'
+import { Link, Route } from 'wouter'
+
+import GooBlobs from './sandboxes/gooBlobs/src/App'
+import Card from './sandboxes/card/src/App'
+
+const links: {
+ [key: string]: () => JSX.Element
+} = {
+ 'goo-blobs': GooBlobs,
+ card: Card,
+}
+
+const Example = ({ link }: { link: string }) => {
+ const Component = links[link]
+ return (
+
+ )
+}
+
+export default function App() {
+ return (
+ <>
+
+ Spring demos
+ Sandboxes
+
+ {Object.keys(links).map(link => (
+
+
{link}
+
+ ))}
+
+
+ {params => }
+ >
+ )
+}
diff --git a/demo/src/index.tsx b/demo/src/index.tsx
new file mode 100644
index 0000000000..d8c7239ceb
--- /dev/null
+++ b/demo/src/index.tsx
@@ -0,0 +1,6 @@
+import * as React from 'react'
+import ReactDOM from 'react-dom'
+import './styles.css'
+import App from './App'
+
+ReactDOM.render(, document.getElementById('root'))
diff --git a/demo/src/sandboxes/card/package.json b/demo/src/sandboxes/card/package.json
new file mode 100644
index 0000000000..4765527706
--- /dev/null
+++ b/demo/src/sandboxes/card/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "spring-card",
+ "version": "1.0.0",
+ "main": "src/index.tsx",
+ "dependencies": {
+ "@react-spring/web": "*",
+ "react": "^17.0.1",
+ "react-dom": "^17.0.1",
+ "react-scripts": "4.0.3"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ],
+ "bic": false,
+ "devDependencies": {}
+}
diff --git a/demo/src/sandboxes/card/public/index.html b/demo/src/sandboxes/card/public/index.html
new file mode 100644
index 0000000000..2a1959d610
--- /dev/null
+++ b/demo/src/sandboxes/card/public/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+ React Spring Sandbox
+
+
+
+
+
+
+
+
diff --git a/demo/src/sandboxes/card/src/App.tsx b/demo/src/sandboxes/card/src/App.tsx
new file mode 100644
index 0000000000..09929a3fb9
--- /dev/null
+++ b/demo/src/sandboxes/card/src/App.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import { useSpring, animated } from '@react-spring/web'
+
+import './index.css'
+
+const calc = (x: number, y: number) => [
+ -(y - window.innerHeight / 2) / 20,
+ (x - window.innerWidth / 2) / 20,
+ 1.1,
+]
+const trans = (x: number, y: number, s: number) =>
+ `perspective(600px) rotateX(${x}deg) rotateY(${y}deg) scale(${s})`
+
+export default function App() {
+ const [props, set] = useSpring(() => ({
+ xys: [0, 0, 1],
+ config: { mass: 5, tension: 350, friction: 40 },
+ }))
+ return (
+ set({ xys: calc(x, y) })}
+ onMouseLeave={() => set({ xys: [0, 0, 1] })}
+ style={{ transform: props.xys.to(trans) }}
+ />
+ )
+}
diff --git a/demo/src/sandboxes/card/src/index.css b/demo/src/sandboxes/card/src/index.css
new file mode 100644
index 0000000000..1a2702a6a5
--- /dev/null
+++ b/demo/src/sandboxes/card/src/index.css
@@ -0,0 +1,48 @@
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ background-color: white;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
+ helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif;
+ background: transparent;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ cursor: default;
+}
+
+#root {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ background: #f0f0f0;
+}
+
+.card {
+ width: 45ch;
+ height: 45ch;
+ background: grey;
+ border-radius: 5px;
+ background-image: url(https://drscdn.500px.org/photo/435236/q%3D80_m%3D1500/v2?webp=true&sig=67031bdff6f582f3e027311e2074be452203ab637c0bd21d89128844becf8e40);
+ background-size: cover;
+ background-position: center center;
+ box-shadow: 0px 10px 30px -5px rgba(0, 0, 0, 0.3);
+ transition: box-shadow 0.5s;
+ will-change: transform;
+ border: 15px solid white;
+}
+
+.card:hover {
+ box-shadow: 0px 30px 100px -10px rgba(0, 0, 0, 0.4);
+}
diff --git a/demo/src/sandboxes/card/src/index.tsx b/demo/src/sandboxes/card/src/index.tsx
new file mode 100644
index 0000000000..32680e24e6
--- /dev/null
+++ b/demo/src/sandboxes/card/src/index.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import App from './App'
+
+const rootElement = document.getElementById('root')
+ReactDOM.render(
+
+
+ ,
+ rootElement
+)
diff --git a/demo/src/sandboxes/gooBlobs/package.json b/demo/src/sandboxes/gooBlobs/package.json
new file mode 100644
index 0000000000..71582c69c5
--- /dev/null
+++ b/demo/src/sandboxes/gooBlobs/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "spring-goo-blobs",
+ "version": "1.0.0",
+ "main": "src/index.tsx",
+ "dependencies": {
+ "@react-spring/web": "*",
+ "react": "^17.0.1",
+ "react-dom": "^17.0.1",
+ "react-scripts": "4.0.3"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test --env=jsdom",
+ "eject": "react-scripts eject"
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ],
+ "bic": false,
+ "devDependencies": {}
+}
diff --git a/demo/src/sandboxes/gooBlobs/public/index.html b/demo/src/sandboxes/gooBlobs/public/index.html
new file mode 100644
index 0000000000..2a1959d610
--- /dev/null
+++ b/demo/src/sandboxes/gooBlobs/public/index.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+ React Spring Sandbox
+
+
+
+
+
+
+
+
diff --git a/demo/src/sandboxes/gooBlobs/src/App.tsx b/demo/src/sandboxes/gooBlobs/src/App.tsx
new file mode 100644
index 0000000000..c12649fbca
--- /dev/null
+++ b/demo/src/sandboxes/gooBlobs/src/App.tsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import { useTrail, animated } from '@react-spring/web'
+
+import './index.css'
+
+const fast = { tension: 1200, friction: 40 }
+const slow = { mass: 10, tension: 200, friction: 50 }
+const trans = (x: number, y: number) =>
+ `translate3d(${x}px,${y}px,0) translate3d(-50%,-50%,0)`
+
+export default function App() {
+ const [trail, set] = useTrail(3, () => ({
+ xy: [0, 0],
+ config: i => (i === 0 ? fast : slow),
+ }))
+ return (
+ <>
+
+ set({ xy: [e.clientX, e.clientY] })}>
+ {trail.map((props, index) => (
+
+ ))}
+
+ >
+ )
+}
diff --git a/demo/src/sandboxes/gooBlobs/src/index.css b/demo/src/sandboxes/gooBlobs/src/index.css
new file mode 100644
index 0000000000..67124f5b87
--- /dev/null
+++ b/demo/src/sandboxes/gooBlobs/src/index.css
@@ -0,0 +1,94 @@
+body {
+ font-family: system-ui;
+ margin: 0;
+}
+
+*,
+*:after,
+*:before {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ background-color: white;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir,
+ helvetica neue, helvetica, ubuntu, roboto, noto, segoe ui, arial, sans-serif;
+ background: transparent;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ cursor: default;
+}
+
+.hooks-main > svg {
+ display: none;
+}
+
+.hooks-main > div {
+ position: absolute;
+ will-change: transform;
+ border-radius: 50%;
+ background: lightcoral;
+ box-shadow: 10px 10px 5px 0px rgba(0, 0, 0, 0.75);
+ opacity: 0.6;
+}
+
+.hooks-main > div:nth-child(1) {
+ width: 120px;
+ height: 120px;
+}
+
+.hooks-main > div:nth-child(2) {
+ width: 250px;
+ height: 250px;
+}
+
+.hooks-main > div:nth-child(3) {
+ width: 150px;
+ height: 150px;
+}
+
+.hooks-main > div::after {
+ content: '';
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.8);
+}
+
+.hooks-main > div:nth-child(2)::after {
+ top: 70px;
+ left: 70px;
+ width: 70px;
+ height: 70px;
+}
+
+.hooks-main > div:nth-child(3)::after {
+ top: 50px;
+ left: 50px;
+ width: 50px;
+ height: 50px;
+}
+
+.hooks-main {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ filter: url('#goo');
+ overflow: hidden;
+}
diff --git a/demo/src/sandboxes/gooBlobs/src/index.tsx b/demo/src/sandboxes/gooBlobs/src/index.tsx
new file mode 100644
index 0000000000..32680e24e6
--- /dev/null
+++ b/demo/src/sandboxes/gooBlobs/src/index.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import App from './App'
+
+const rootElement = document.getElementById('root')
+ReactDOM.render(
+
+
+ ,
+ rootElement
+)
diff --git a/demo/src/styles.css b/demo/src/styles.css
new file mode 100644
index 0000000000..4cdf759260
--- /dev/null
+++ b/demo/src/styles.css
@@ -0,0 +1,46 @@
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.main {
+ position: relative;
+ width: 200px;
+ height: 50px;
+ background: #272727;
+ cursor: pointer;
+ border-radius: 5px;
+ border: 2px solid white;
+ overflow: hidden;
+}
+
+.fill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: hotpink;
+}
+
+.content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
+ Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
new file mode 100644
index 0000000000..8f2c75f758
--- /dev/null
+++ b/demo/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "types": ["vite/client"],
+ "allowJs": false,
+ "skipLibCheck": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react",
+ "paths": {
+ "@react-spring/web": ["../targets/web/src/index.ts"]
+ }
+ },
+ "include": ["./src"]
+}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
new file mode 100644
index 0000000000..2fb34ae8ae
--- /dev/null
+++ b/demo/vite.config.ts
@@ -0,0 +1,12 @@
+import path from 'path'
+import { defineConfig } from 'vite'
+import reactRefresh from '@vitejs/plugin-react-refresh'
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@react-spring/web': path.resolve('../targets/web/src/index.ts'),
+ },
+ },
+ plugins: [reactRefresh()],
+})
diff --git a/demo/yarn.lock b/demo/yarn.lock
new file mode 100644
index 0000000000..e9435209af
--- /dev/null
+++ b/demo/yarn.lock
@@ -0,0 +1,547 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@babel/code-frame@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658"
+ integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==
+ dependencies:
+ "@babel/highlight" "^7.12.13"
+
+"@babel/compat-data@^7.13.8":
+ version "7.13.11"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.11.tgz#9c8fe523c206979c9a81b1e12fe50c1254f1aa35"
+ integrity sha512-BwKEkO+2a67DcFeS3RLl0Z3Gs2OvdXewuWjc1Hfokhb5eQWP9YRYH1/+VrVZvql2CfjOiNGqSAFOYt4lsqTHzg==
+
+"@babel/core@^7.12.13":
+ version "7.13.10"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.10.tgz#07de050bbd8193fcd8a3c27918c0890613a94559"
+ integrity sha512-bfIYcT0BdKeAZrovpMqX2Mx5NrgAckGbwT982AkdS5GNfn3KMGiprlBAtmBcFZRUmpaufS6WZFP8trvx8ptFDw==
+ dependencies:
+ "@babel/code-frame" "^7.12.13"
+ "@babel/generator" "^7.13.9"
+ "@babel/helper-compilation-targets" "^7.13.10"
+ "@babel/helper-module-transforms" "^7.13.0"
+ "@babel/helpers" "^7.13.10"
+ "@babel/parser" "^7.13.10"
+ "@babel/template" "^7.12.13"
+ "@babel/traverse" "^7.13.0"
+ "@babel/types" "^7.13.0"
+ convert-source-map "^1.7.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.2"
+ json5 "^2.1.2"
+ lodash "^4.17.19"
+ semver "^6.3.0"
+ source-map "^0.5.0"
+
+"@babel/generator@^7.13.0", "@babel/generator@^7.13.9":
+ version "7.13.9"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39"
+ integrity sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==
+ dependencies:
+ "@babel/types" "^7.13.0"
+ jsesc "^2.5.1"
+ source-map "^0.5.0"
+
+"@babel/helper-compilation-targets@^7.13.10":
+ version "7.13.10"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.10.tgz#1310a1678cb8427c07a753750da4f8ce442bdd0c"
+ integrity sha512-/Xju7Qg1GQO4mHZ/Kcs6Au7gfafgZnwm+a7sy/ow/tV1sHeraRUHbjdat8/UvDor4Tez+siGKDk6zIKtCPKVJA==
+ dependencies:
+ "@babel/compat-data" "^7.13.8"
+ "@babel/helper-validator-option" "^7.12.17"
+ browserslist "^4.14.5"
+ semver "^6.3.0"
+
+"@babel/helper-function-name@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a"
+ integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==
+ dependencies:
+ "@babel/helper-get-function-arity" "^7.12.13"
+ "@babel/template" "^7.12.13"
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-get-function-arity@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583"
+ integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==
+ dependencies:
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-member-expression-to-functions@^7.13.0":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.0.tgz#6aa4bb678e0f8c22f58cdb79451d30494461b091"
+ integrity sha512-yvRf8Ivk62JwisqV1rFRMxiSMDGnN6KH1/mDMmIrij4jztpQNRoHqqMG3U6apYbGRPJpgPalhva9Yd06HlUxJQ==
+ dependencies:
+ "@babel/types" "^7.13.0"
+
+"@babel/helper-module-imports@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0"
+ integrity sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g==
+ dependencies:
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-module-transforms@^7.13.0":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.13.0.tgz#42eb4bd8eea68bab46751212c357bfed8b40f6f1"
+ integrity sha512-Ls8/VBwH577+pw7Ku1QkUWIyRRNHpYlts7+qSqBBFCW3I8QteB9DxfcZ5YJpOwH6Ihe/wn8ch7fMGOP1OhEIvw==
+ dependencies:
+ "@babel/helper-module-imports" "^7.12.13"
+ "@babel/helper-replace-supers" "^7.13.0"
+ "@babel/helper-simple-access" "^7.12.13"
+ "@babel/helper-split-export-declaration" "^7.12.13"
+ "@babel/helper-validator-identifier" "^7.12.11"
+ "@babel/template" "^7.12.13"
+ "@babel/traverse" "^7.13.0"
+ "@babel/types" "^7.13.0"
+ lodash "^4.17.19"
+
+"@babel/helper-optimise-call-expression@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea"
+ integrity sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==
+ dependencies:
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-plugin-utils@^7.12.13":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af"
+ integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==
+
+"@babel/helper-replace-supers@^7.13.0":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.13.0.tgz#6034b7b51943094cb41627848cb219cb02be1d24"
+ integrity sha512-Segd5me1+Pz+rmN/NFBOplMbZG3SqRJOBlY+mA0SxAv6rjj7zJqr1AVr3SfzUVTLCv7ZLU5FycOM/SBGuLPbZw==
+ dependencies:
+ "@babel/helper-member-expression-to-functions" "^7.13.0"
+ "@babel/helper-optimise-call-expression" "^7.12.13"
+ "@babel/traverse" "^7.13.0"
+ "@babel/types" "^7.13.0"
+
+"@babel/helper-simple-access@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.13.tgz#8478bcc5cacf6aa1672b251c1d2dde5ccd61a6c4"
+ integrity sha512-0ski5dyYIHEfwpWGx5GPWhH35j342JaflmCeQmsPWcrOQDtCN6C1zKAVRFVbK53lPW2c9TsuLLSUDf0tIGJ5hA==
+ dependencies:
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-split-export-declaration@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05"
+ integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==
+ dependencies:
+ "@babel/types" "^7.12.13"
+
+"@babel/helper-validator-identifier@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
+ integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
+
+"@babel/helper-validator-option@^7.12.17":
+ version "7.12.17"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831"
+ integrity sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==
+
+"@babel/helpers@^7.13.10":
+ version "7.13.10"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.13.10.tgz#fd8e2ba7488533cdeac45cc158e9ebca5e3c7df8"
+ integrity sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==
+ dependencies:
+ "@babel/template" "^7.12.13"
+ "@babel/traverse" "^7.13.0"
+ "@babel/types" "^7.13.0"
+
+"@babel/highlight@^7.12.13":
+ version "7.13.10"
+ resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1"
+ integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.12.11"
+ chalk "^2.0.0"
+ js-tokens "^4.0.0"
+
+"@babel/parser@^7.12.13", "@babel/parser@^7.13.0", "@babel/parser@^7.13.10":
+ version "7.13.11"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.11.tgz#f93ebfc99d21c1772afbbaa153f47e7ce2f50b88"
+ integrity sha512-PhuoqeHoO9fc4ffMEVk4qb/w/s2iOSWohvbHxLtxui0eBg3Lg5gN1U8wp1V1u61hOWkPQJJyJzGH6Y+grwkq8Q==
+
+"@babel/plugin-transform-react-jsx-self@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.13.tgz#422d99d122d592acab9c35ea22a6cfd9bf189f60"
+ integrity sha512-FXYw98TTJ125GVCCkFLZXlZ1qGcsYqNQhVBQcZjyrwf8FEUtVfKIoidnO8S0q+KBQpDYNTmiGo1gn67Vti04lQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.12.13"
+
+"@babel/plugin-transform-react-jsx-source@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.13.tgz#051d76126bee5c9a6aa3ba37be2f6c1698856bcb"
+ integrity sha512-O5JJi6fyfih0WfDgIJXksSPhGP/G0fQpfxYy87sDc+1sFmsCS6wr3aAn+whbzkhbjtq4VMqLRaSzR6IsshIC0Q==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.12.13"
+
+"@babel/template@^7.12.13":
+ version "7.12.13"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327"
+ integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==
+ dependencies:
+ "@babel/code-frame" "^7.12.13"
+ "@babel/parser" "^7.12.13"
+ "@babel/types" "^7.12.13"
+
+"@babel/traverse@^7.13.0":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.0.tgz#6d95752475f86ee7ded06536de309a65fc8966cc"
+ integrity sha512-xys5xi5JEhzC3RzEmSGrs/b3pJW/o87SypZ+G/PhaE7uqVQNv/jlmVIBXuoh5atqQ434LfXV+sf23Oxj0bchJQ==
+ dependencies:
+ "@babel/code-frame" "^7.12.13"
+ "@babel/generator" "^7.13.0"
+ "@babel/helper-function-name" "^7.12.13"
+ "@babel/helper-split-export-declaration" "^7.12.13"
+ "@babel/parser" "^7.13.0"
+ "@babel/types" "^7.13.0"
+ debug "^4.1.0"
+ globals "^11.1.0"
+ lodash "^4.17.19"
+
+"@babel/types@^7.12.13", "@babel/types@^7.13.0":
+ version "7.13.0"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.0.tgz#74424d2816f0171b4100f0ab34e9a374efdf7f80"
+ integrity sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.12.11"
+ lodash "^4.17.19"
+ to-fast-properties "^2.0.0"
+
+"@types/prop-types@*":
+ version "15.7.3"
+ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
+ integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
+
+"@types/react-dom@^17.0.2":
+ version "17.0.2"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.2.tgz#35654cf6c49ae162d5bc90843d5437dc38008d43"
+ integrity sha512-Icd9KEgdnFfJs39KyRyr0jQ7EKhq8U6CcHRMGAS45fp5qgUvxL3ujUCfWFttUK2UErqZNj97t9gsVPNAqcwoCg==
+ dependencies:
+ "@types/react" "*"
+
+"@types/react@*", "@types/react@^17.0.3":
+ version "17.0.3"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79"
+ integrity sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg==
+ dependencies:
+ "@types/prop-types" "*"
+ "@types/scheduler" "*"
+ csstype "^3.0.2"
+
+"@types/scheduler@*":
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275"
+ integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==
+
+"@vitejs/plugin-react-refresh@^1.3.1":
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/@vitejs/plugin-react-refresh/-/plugin-react-refresh-1.3.1.tgz#dfdb17e9924ba57fc4951ac882e39628051a30f4"
+ integrity sha512-VxHkrvOOTnMGWq9BveEN5ufmfDfaGxfawCymlKdF+X0RApCr0jQFzOyewhmSSCgGHjqnpRj+7TTDebjBkB3qhg==
+ dependencies:
+ "@babel/core" "^7.12.13"
+ "@babel/plugin-transform-react-jsx-self" "^7.12.13"
+ "@babel/plugin-transform-react-jsx-source" "^7.12.13"
+ react-refresh "^0.9.0"
+
+ansi-styles@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+ integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+ dependencies:
+ color-convert "^1.9.0"
+
+browserslist@^4.14.5:
+ version "4.16.3"
+ resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
+ integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==
+ dependencies:
+ caniuse-lite "^1.0.30001181"
+ colorette "^1.2.1"
+ electron-to-chromium "^1.3.649"
+ escalade "^3.1.1"
+ node-releases "^1.1.70"
+
+caniuse-lite@^1.0.30001181:
+ version "1.0.30001203"
+ resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001203.tgz#a7a34df21a387d9deffcd56c000b8cf5ab540580"
+ integrity sha512-/I9tvnzU/PHMH7wBPrfDMSuecDeUKerjCPX7D0xBbaJZPxoT9m+yYxt0zCTkcijCkjTdim3H56Zm0i5Adxch4w==
+
+chalk@^2.0.0:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
+ integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
+ dependencies:
+ ansi-styles "^3.2.1"
+ escape-string-regexp "^1.0.5"
+ supports-color "^5.3.0"
+
+color-convert@^1.9.0:
+ version "1.9.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
+ integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
+ dependencies:
+ color-name "1.1.3"
+
+color-name@1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
+ integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
+
+colorette@^1.2.1, colorette@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
+ integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
+
+convert-source-map@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
+ integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
+ dependencies:
+ safe-buffer "~5.1.1"
+
+csstype@^3.0.2:
+ version "3.0.7"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.7.tgz#2a5fb75e1015e84dd15692f71e89a1450290950b"
+ integrity sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==
+
+debug@^4.1.0:
+ version "4.3.1"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
+ integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+ dependencies:
+ ms "2.1.2"
+
+electron-to-chromium@^1.3.649:
+ version "1.3.693"
+ resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.693.tgz#5089c506a925c31f93fcb173a003a22e341115dd"
+ integrity sha512-vUdsE8yyeu30RecppQtI+XTz2++LWLVEIYmzeCaCRLSdtKZ2eXqdJcrs85KwLiPOPVc6PELgWyXBsfqIvzGZag==
+
+esbuild@^0.9.3:
+ version "0.9.5"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.9.5.tgz#200e7836580c0a78849ee257656efd40dbf0b4df"
+ integrity sha512-ZBOtZX9HxgSdwkAroVifEUgylWqbOjqbu1i1ohRvhoaYt6aj81dxbizODx419gwDAupqut44ehFVwUq7WRN/OA==
+
+escalade@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+ integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+
+escape-string-regexp@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
+ integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
+
+fsevents@~2.3.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
+function-bind@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+ integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+gensync@^1.0.0-beta.2:
+ version "1.0.0-beta.2"
+ resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
+ integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+
+globals@^11.1.0:
+ version "11.12.0"
+ resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+ integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
+
+has-flag@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
+ integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
+
+has@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+ integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+ dependencies:
+ function-bind "^1.1.1"
+
+is-core-module@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a"
+ integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==
+ dependencies:
+ has "^1.0.3"
+
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
+ integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+
+jsesc@^2.5.1:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
+ integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
+
+json5@^2.1.2:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
+ integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
+ dependencies:
+ minimist "^1.2.5"
+
+lodash@^4.17.19:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+ integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
+loose-envify@^1.1.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
+ integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
+ dependencies:
+ js-tokens "^3.0.0 || ^4.0.0"
+
+minimist@^1.2.5:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+ integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
+
+ms@2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+ integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+nanoid@^3.1.20:
+ version "3.1.22"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
+ integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==
+
+node-releases@^1.1.70:
+ version "1.1.71"
+ resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
+ integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
+
+object-assign@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+ integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+path-parse@^1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+ integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+
+postcss@^8.2.1:
+ version "8.2.8"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.8.tgz#0b90f9382efda424c4f0f69a2ead6f6830d08ece"
+ integrity sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw==
+ dependencies:
+ colorette "^1.2.2"
+ nanoid "^3.1.20"
+ source-map "^0.6.1"
+
+react-dom@^17.0.1:
+ version "17.0.1"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6"
+ integrity sha512-6eV150oJZ9U2t9svnsspTMrWNyHc6chX0KzDeAOXftRa8bNeOKTTfCJ7KorIwenkHd2xqVTBTCZd79yk/lx/Ug==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+ scheduler "^0.20.1"
+
+react-refresh@^0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"
+ integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==
+
+react@^17.0.1:
+ version "17.0.1"
+ resolved "https://registry.yarnpkg.com/react/-/react-17.0.1.tgz#6e0600416bd57574e3f86d92edba3d9008726127"
+ integrity sha512-lG9c9UuMHdcAexXtigOZLX8exLWkW0Ku29qPRU8uhF2R9BN96dLCt0psvzPLlHc5OWkgymP3qwTRgbnw5BKx3w==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+
+resolve@^1.19.0:
+ version "1.20.0"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
+ integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
+ dependencies:
+ is-core-module "^2.2.0"
+ path-parse "^1.0.6"
+
+rollup@^2.38.5:
+ version "2.42.1"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.42.1.tgz#6d675b7971e3bee510935326a0f7e556bb7d43de"
+ integrity sha512-/y7M2ULg06JOXmMpPzhTeQroJSchy8lX8q6qrjqil0jmLz6ejCWbQzVnWTsdmMQRhfU0QcwtiW8iZlmrGXWV4g==
+ optionalDependencies:
+ fsevents "~2.3.1"
+
+safe-buffer@~5.1.1:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+ integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+scheduler@^0.20.1:
+ version "0.20.1"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c"
+ integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw==
+ dependencies:
+ loose-envify "^1.1.0"
+ object-assign "^4.1.1"
+
+semver@^6.3.0:
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+ integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+
+source-map@^0.5.0:
+ version "0.5.7"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
+ integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
+
+source-map@^0.6.1:
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+ integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+supports-color@^5.3.0:
+ version "5.5.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
+ integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
+ dependencies:
+ has-flag "^3.0.0"
+
+to-fast-properties@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+ integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
+
+typescript@^4.2.3:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3"
+ integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==
+
+vite@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/vite/-/vite-2.1.2.tgz#0aecaf6d34112b24536df1a14cd8d74fdcab6e20"
+ integrity sha512-K96k5Nb1kywggFwZNGf/NQVZIrjMSvjebYWFIEQRu8AQWtzxatMF8/reExFXebmrfWAT3PTUk6l4zJBkpMtyVg==
+ dependencies:
+ esbuild "^0.9.3"
+ postcss "^8.2.1"
+ resolve "^1.19.0"
+ rollup "^2.38.5"
+ optionalDependencies:
+ fsevents "~2.3.1"
+
+wouter@^2.7.4:
+ version "2.7.4"
+ resolved "https://registry.yarnpkg.com/wouter/-/wouter-2.7.4.tgz#d528122cacf6e9805d7b953bbddfb90226850977"
+ integrity sha512-shJIsHR+gcn69L2zR+0eKquOvAcjfIEdhzf8iAP502DlrAwg5lO2Q90DqIvXryNz/mk1fe2crTB40ZHXjoF1Bg==
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000000..d2fd25047d
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,59 @@
+const fs = require('fs-extra')
+const path = require('path')
+const { recrawl } = require('recrawl-sync')
+
+const testMatch = ['**/*.test.*']
+const ignoredPaths = ['.*', 'node_modules']
+const findTests = recrawl({
+ only: testMatch,
+ skip: ignoredPaths,
+})
+
+const PJ = 'package.json'
+const { packages } = fs.readJsonSync(PJ).workspaces
+const findProjects = recrawl({
+ only: packages.map(glob => path.join(glob, PJ)),
+ skip: ignoredPaths,
+})
+
+module.exports = {
+ projects: getProjects(),
+ watchPlugins: [
+ 'jest-watch-typeahead/filename',
+ 'jest-watch-typeahead/testname',
+ ],
+}
+
+function getProjects() {
+ return findProjects('.')
+ .map(jsonPath => path.resolve(jsonPath, '..'))
+ .filter(dir => findTests(dir).length > 0)
+ .map(createConfig)
+}
+
+function createConfig(rootDir) {
+ const { compilerOptions } = fs.readJsonSync(
+ path.join(rootDir, 'tsconfig.json')
+ )
+ return {
+ rootDir,
+ setupFilesAfterEnv:
+ rootDir.indexOf('shared') < 0
+ ? [path.join(__dirname, 'packages/core/test/setup.ts')]
+ : [],
+ testMatch,
+ testEnvironment: 'jsdom',
+ testPathIgnorePatterns: ['.+/(types|__snapshots__)/.+'],
+ modulePathIgnorePatterns: ['dist'],
+ moduleNameMapper: {
+ '^react$': '/../../node_modules/react',
+ },
+ transform: {
+ '^.+\\.tsx?$': 'esbuild-jest',
+ },
+ collectCoverageFrom: ['src/**/*'],
+ coverageDirectory: './coverage',
+ coverageReporters: ['json', 'html', 'text'],
+ timers: 'fake',
+ }
+}
diff --git a/lerna.json b/lerna.json
new file mode 100644
index 0000000000..997915c380
--- /dev/null
+++ b/lerna.json
@@ -0,0 +1,24 @@
+{
+ "version": "9.0.0-rc.4",
+ "npmClient": "yarn",
+ "useWorkspaces": true,
+ "registry": "https://registry.npmjs.org",
+ "ignoreChanges": [
+ "**/__tests__/**",
+ "**/__snapshots__/**",
+ "**/*.test.*",
+ "**/*.md"
+ ],
+ "command": {
+ "version": {
+ "changelog": false,
+ "conventionalCommits": true,
+ "message": "%v",
+ "push": false
+ },
+ "publish": {
+ "contents": "dist",
+ "ignoreScripts": true
+ }
+ }
+}
diff --git a/package.json b/package.json
index 5e13334239..90d687f20d 100644
--- a/package.json
+++ b/package.json
@@ -1,134 +1,98 @@
{
- "name": "react-spring",
- "version": "8.0.27",
- "description": "A set of spring-physics based animation primitives",
- "main": "web.cjs.js",
- "module": "web.js",
- "react-native": "native.js",
+ "name": "@react-spring/lerna",
"private": true,
- "sideEffects": false,
+ "description": "Cross-platform animation engine for React",
+ "repository": "pmndrs/react-spring",
+ "homepage": "https://github.com/pmndrs/react-spring#readme",
+ "keywords": [
+ "animated",
+ "animation",
+ "hooks",
+ "motion",
+ "react",
+ "react-native",
+ "spring",
+ "typescript",
+ "velocity"
+ ],
+ "license": "MIT",
+ "author": "Paul Henschel",
+ "maintainers": [],
+ "workspaces": {
+ "packages": [
+ "packages/*",
+ "targets/*"
+ ],
+ "nohoist": [
+ "**"
+ ]
+ },
"scripts": {
- "prebuild": "rimraf dist",
- "docz": "docz dev",
- "docz:build": "docz build && cp .docz/dist/index.html .docz/dist/200.html && cp examples/CNAME .docz/dist/CNAME",
- "build": "npm-run-all --parallel copy rollup",
- "copy": "copyfiles -f package.json readme.md LICENSE.md \"types/*\" dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.husky=undefined; this.prettier=undefined; this.jest=undefined;\"",
- "rollup": "rollup -c",
- "prepare": "npm run build",
+ "build": "bic",
+ "clean": "lerna exec --parallel --no-bail -- rimraf node_modules dist .rpt2_cache .bic_cache",
+ "prepare": "yarn build && node ./scripts/prepare.js",
+ "release": "node ./scripts/release.js",
"test": "jest",
- "test:dev": "jest --watch --no-coverage",
- "test:coverage:watch": "jest --watch",
- "test:ts": "tsc --noEmit",
- "postinstall": "node -e \"console.log('\\u001b[35m\\u001b[1mEnjoy react-spring? You can now donate to our open collective:\\u001b[22m\\u001b[39m\\n > \\u001b[34mhttps://opencollective.com/react-spring/donate\\u001b[0m')\""
+ "test:cov": "jest --coverage",
+ "test:ts": "cd packages/react-spring && tsc -p . --noEmit"
+ },
+ "commitlint": {
+ "extends": [
+ "@commitlint/config-conventional"
+ ],
+ "rules": {
+ "body-max-line-length": [
+ 0
+ ]
+ }
},
"husky": {
"hooks": {
+ "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "pretty-quick --staged"
}
},
- "prettier": {
- "semi": false,
- "trailingComma": "es5",
- "singleQuote": true,
- "jsxBracketSameLine": true,
- "tabWidth": 2,
- "printWidth": 80
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/drcmda/react-spring.git"
- },
- "keywords": [
- "react",
- "motion",
- "animated",
- "animation",
- "spring"
- ],
- "author": "Paul Henschel",
- "contributors": [
- "Alec Larson (https://github.com/aleclarson)"
- ],
- "license": "MIT",
- "bugs": {
- "url": "https://github.com/drcmda/react-spring/issues"
- },
- "homepage": "https://github.com/drcmda/react-spring#readme",
"devDependencies": {
- "@babel/core": "7.2.2",
- "@babel/plugin-proposal-class-properties": "7.3.0",
- "@babel/plugin-proposal-do-expressions": "7.2.0",
- "@babel/plugin-proposal-object-rest-spread": "7.3.2",
- "@babel/plugin-transform-modules-commonjs": "7.2.0",
- "@babel/plugin-transform-parameters": "7.2.0",
- "@babel/plugin-transform-runtime": "7.2.0",
- "@babel/plugin-transform-template-literals": "7.2.0",
- "@babel/preset-env": "7.3.1",
- "@babel/preset-react": "7.0.0",
- "@babel/preset-typescript": "^7.1.0",
- "@types/jest": "^24.0.0",
- "@types/mock-raf": "^1.0.2",
- "@types/react": "16.8.2",
- "babel-core": "7.0.0-bridge.0",
- "babel-jest": "24.1.0",
- "babel-plugin-transform-react-remove-prop-types": "0.4.24",
- "babel-polyfill": "6.26.0",
- "copyfiles": "2.1.0",
- "enzyme": "3.8.0",
- "enzyme-adapter-react-16": "1.9.1",
+ "@babel/core": "^7.11.6",
+ "@babel/preset-env": "^7.11.5",
+ "@commitlint/cli": "^11.0.0",
+ "@commitlint/config-conventional": "^11.0.0",
+ "@rollup/plugin-babel": "^5.2.1",
+ "@rollup/plugin-commonjs": "^11.1.0",
+ "@rollup/plugin-node-resolve": "^7.1.3",
+ "@types/jest": "^24.0.13",
+ "@types/react": "^16.8.19",
+ "build-if-changed": "^1.5.0",
+ "chalk": "^2.4.2",
+ "copyfiles": "^2.4.1",
+ "enquirer": "^2.3.2",
+ "esbuild": "^0.8.0",
+ "esbuild-jest": "^0.2.2",
+ "execa": "^2.0.4",
+ "flush-microtasks": "^1.0.1",
+ "fs-extra": "7.0.1",
"husky": "1.3.1",
- "jest": "24.1.0",
- "json": "9.0.6",
- "konva": "^2.6.0",
- "mock-raf": "1.0.1",
- "npm-run-all": "4.1.5",
- "prettier": "1.16.4",
+ "jest": "^25.1.0",
+ "jest-watch-typeahead": "^0.3.1",
+ "lerna": "3.15.0",
+ "meta": "^1.2.19",
+ "mock-raf": "npm:@react-spring/mock-raf",
+ "prettier": "^2.0.5",
"pretty-quick": "1.10.0",
- "react": "16.8.1",
- "react-dom": "16.8.1",
- "react-konva": "^16.8.0",
- "react-native": "^0.58.4",
- "react-test-renderer": "16.8.1",
- "react-testing-library": "5.6.1",
+ "react": "~16.9.0",
+ "recrawl-sync": "^1.2.2",
"rimraf": "2.6.3",
- "rollup": "1.1.2",
- "rollup-plugin-babel": "4.3.2",
- "rollup-plugin-commonjs": "9.2.0",
- "rollup-plugin-node-resolve": "4.0.0",
- "rollup-plugin-size-snapshot": "0.8.0",
- "rollup-plugin-uglify": "6.0.2",
- "typescript": "3.3.3"
- },
- "peerDependencies": {
- "react": ">= 16.8.0",
- "react-dom": ">= 16.8.0"
- },
- "dependencies": {
- "@babel/runtime": "^7.3.1",
- "prop-types": "^15.5.8"
+ "rollup": "^2.7.6",
+ "rollup-plugin-dts": "^1.4.11",
+ "rollup-plugin-esbuild": "^2.5.0",
+ "sade": "^1.6.1",
+ "sort-package-json": "1.22.1",
+ "spec.ts": "1.1.3",
+ "typescript": "^4.0.0",
+ "typescript-rewrite-paths": "^1.2.0"
},
- "jest": {
- "testPathIgnorePatterns": [
- "/node_modules/",
- "jest",
- "legacy"
- ],
- "testRegex": "test.(js|ts|tsx)$",
- "coverageDirectory": "./coverage/",
- "collectCoverage": true,
- "coverageReporters": [
- "json",
- "html",
- "text",
- "text-summary"
- ],
- "collectCoverageFrom": [
- "src/**/*.js",
- "!test/"
- ],
- "setupFilesAfterEnv": [
- "/setupTests.js"
- ]
+ "publishConfig": {
+ "access": "public"
},
"collective": {
"type": "opencollective",
diff --git a/src/animated/LICENSE b/packages/animated/LICENSE
similarity index 99%
rename from src/animated/LICENSE
rename to packages/animated/LICENSE
index 5930f2b8d8..188fb2b0bd 100644
--- a/src/animated/LICENSE
+++ b/packages/animated/LICENSE
@@ -18,4 +18,4 @@ 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
+SOFTWARE.
diff --git a/packages/animated/README.md b/packages/animated/README.md
new file mode 100644
index 0000000000..8a91ac8058
--- /dev/null
+++ b/packages/animated/README.md
@@ -0,0 +1,3 @@
+# @react-spring/animated
+
+Fork of [animated](https://github.com/animatedjs/animated)
diff --git a/packages/animated/package.json b/packages/animated/package.json
new file mode 100644
index 0000000000..78085a7ad2
--- /dev/null
+++ b/packages/animated/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@react-spring/animated",
+ "version": "9.0.0-rc.4",
+ "description": "Animated component props for React",
+ "main": "src/index.ts",
+ "scripts": {
+ "build": "rollup -c"
+ },
+ "dependencies": {
+ "@react-spring/shared": "link:../shared",
+ "@react-spring/types": "link:../types",
+ "react-layout-effect": "^1.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ },
+ "publishConfig": {
+ "directory": "dist"
+ }
+}
diff --git a/packages/animated/rollup.config.js b/packages/animated/rollup.config.js
new file mode 100644
index 0000000000..66cee09cf3
--- /dev/null
+++ b/packages/animated/rollup.config.js
@@ -0,0 +1,3 @@
+import { bundle } from '../../rollup.config'
+
+export default bundle()
diff --git a/packages/animated/src/Animated.ts b/packages/animated/src/Animated.ts
new file mode 100644
index 0000000000..14c02ffa8d
--- /dev/null
+++ b/packages/animated/src/Animated.ts
@@ -0,0 +1,45 @@
+import { defineHidden } from '@react-spring/shared'
+import { AnimatedValue } from './AnimatedValue'
+
+const $node: any = Symbol.for('Animated:node')
+
+export const isAnimated = (value: any): value is Animated =>
+ !!value && value[$node] === value
+
+/** Get the owner's `Animated` node. */
+export const getAnimated = (owner: any): Animated | undefined =>
+ owner && owner[$node]
+
+/** Set the owner's `Animated` node. */
+export const setAnimated = (owner: any, node: Animated) =>
+ defineHidden(owner, $node, node)
+
+/** Get every `AnimatedValue` in the owner's `Animated` node. */
+export const getPayload = (owner: any): AnimatedValue[] | undefined =>
+ owner && owner[$node] && owner[$node].getPayload()
+
+export abstract class Animated {
+ /** The cache of animated values */
+ protected payload?: Payload
+
+ constructor() {
+ // This makes "isAnimated" return true.
+ setAnimated(this, this)
+ }
+
+ /** Get the current value. Pass `true` for only animated values. */
+ abstract getValue(animated?: boolean): T
+
+ /** Set the current value. Returns `true` if the value changed. */
+ abstract setValue(value: T): boolean | void
+
+ /** Reset any animation state. */
+ abstract reset(goal?: T): void
+
+ /** Get every `AnimatedValue` used by this node. */
+ getPayload(): Payload {
+ return this.payload || []
+ }
+}
+
+export type Payload = readonly AnimatedValue[]
diff --git a/packages/animated/src/AnimatedArray.ts b/packages/animated/src/AnimatedArray.ts
new file mode 100644
index 0000000000..ef96877118
--- /dev/null
+++ b/packages/animated/src/AnimatedArray.ts
@@ -0,0 +1,42 @@
+import { isAnimatedString } from '@react-spring/shared'
+import { AnimatedObject } from './AnimatedObject'
+import { AnimatedString } from './AnimatedString'
+import { AnimatedValue } from './AnimatedValue'
+
+type Value = number | string
+type Source = AnimatedValue[]
+
+/** An array of animated nodes */
+export class AnimatedArray<
+ T extends ReadonlyArray = Value[]
+> extends AnimatedObject {
+ protected source!: Source
+ constructor(source: T) {
+ super(source)
+ }
+
+ /** @internal */
+ static create>(source: T) {
+ return new AnimatedArray(source)
+ }
+
+ getValue(): T {
+ return this.source.map(node => node.getValue()) as any
+ }
+
+ setValue(source: T) {
+ const payload = this.getPayload()
+ // Reuse the payload when lengths are equal.
+ if (source.length == payload.length) {
+ return payload.some((node, i) => node.setValue(source[i]))
+ }
+ // Remake the payload when length changes.
+ super.setValue(source.map(makeAnimated))
+ return true
+ }
+}
+
+function makeAnimated(value: any) {
+ const nodeType = isAnimatedString(value) ? AnimatedString : AnimatedValue
+ return nodeType.create(value)
+}
diff --git a/packages/animated/src/AnimatedObject.ts b/packages/animated/src/AnimatedObject.ts
new file mode 100644
index 0000000000..c7c069c1a7
--- /dev/null
+++ b/packages/animated/src/AnimatedObject.ts
@@ -0,0 +1,64 @@
+import { Lookup } from '@react-spring/types'
+import {
+ each,
+ eachProp,
+ getFluidValue,
+ hasFluidValue,
+} from '@react-spring/shared'
+import { Animated, isAnimated, getPayload } from './Animated'
+import { AnimatedValue } from './AnimatedValue'
+import { TreeContext } from './context'
+
+/** An object containing `Animated` nodes */
+export class AnimatedObject extends Animated {
+ constructor(protected source: Lookup) {
+ super()
+ this.setValue(source)
+ }
+
+ getValue(animated?: boolean) {
+ const values: Lookup = {}
+ eachProp(this.source, (source, key) => {
+ if (isAnimated(source)) {
+ values[key] = source.getValue(animated)
+ } else if (hasFluidValue(source)) {
+ values[key] = getFluidValue(source)
+ } else if (!animated) {
+ values[key] = source
+ }
+ })
+ return values
+ }
+
+ /** Replace the raw object data */
+ setValue(source: Lookup) {
+ this.source = source
+ this.payload = this._makePayload(source)
+ }
+
+ reset() {
+ if (this.payload) {
+ each(this.payload, node => node.reset())
+ }
+ }
+
+ /** Create a payload set. */
+ protected _makePayload(source: Lookup) {
+ if (source) {
+ const payload = new Set()
+ eachProp(source, this._addToPayload, payload)
+ return Array.from(payload)
+ }
+ }
+
+ /** Add to a payload set. */
+ protected _addToPayload(this: Set, source: any) {
+ if (TreeContext.dependencies && hasFluidValue(source)) {
+ TreeContext.dependencies.add(source)
+ }
+ const payload = getPayload(source)
+ if (payload) {
+ each(payload, node => this.add(node))
+ }
+ }
+}
diff --git a/packages/animated/src/AnimatedString.ts b/packages/animated/src/AnimatedString.ts
new file mode 100644
index 0000000000..1419cf12ff
--- /dev/null
+++ b/packages/animated/src/AnimatedString.ts
@@ -0,0 +1,52 @@
+import { AnimatedValue } from './AnimatedValue'
+import { is, createInterpolator } from '@react-spring/shared'
+
+type Value = string | number
+
+export class AnimatedString extends AnimatedValue {
+ protected _value!: number
+ protected _string: string | null = null
+ protected _toString: (input: number) => string
+
+ constructor(value: string) {
+ super(0)
+ this._toString = createInterpolator({
+ output: [value, value],
+ })
+ }
+
+ /** @internal */
+ static create(value: string) {
+ return new AnimatedString(value)
+ }
+
+ getValue() {
+ let value = this._string
+ return value == null ? (this._string = this._toString(this._value)) : value
+ }
+
+ setValue(value: Value) {
+ if (is.str(value)) {
+ if (value == this._string) {
+ return false
+ }
+ this._string = value
+ this._value = 1
+ } else if (super.setValue(value)) {
+ this._string = null
+ } else {
+ return false
+ }
+ return true
+ }
+
+ reset(goal?: string) {
+ if (goal) {
+ this._toString = createInterpolator({
+ output: [this.getValue(), goal],
+ })
+ }
+ this._value = 0
+ super.reset()
+ }
+}
diff --git a/packages/animated/src/AnimatedValue.ts b/packages/animated/src/AnimatedValue.ts
new file mode 100644
index 0000000000..49ead6c484
--- /dev/null
+++ b/packages/animated/src/AnimatedValue.ts
@@ -0,0 +1,59 @@
+import { is } from '@react-spring/shared'
+import { Animated, Payload } from './Animated'
+
+/** An animated number or a native attribute value */
+export class AnimatedValue extends Animated {
+ done = true
+ elapsedTime!: number
+ lastPosition!: number
+ lastVelocity?: number | null
+ v0?: number | null
+
+ constructor(protected _value: T) {
+ super()
+ if (is.num(this._value)) {
+ this.lastPosition = this._value
+ }
+ }
+
+ /** @internal */
+ static create(value: any) {
+ return new AnimatedValue(value)
+ }
+
+ getPayload(): Payload {
+ return [this]
+ }
+
+ getValue() {
+ return this._value
+ }
+
+ setValue(value: T, step?: number) {
+ if (is.num(value)) {
+ this.lastPosition = value
+ if (step) {
+ value = (Math.round(value / step) * step) as any
+ if (this.done) {
+ this.lastPosition = value as any
+ }
+ }
+ }
+ if (this._value === value) {
+ return false
+ }
+ this._value = value
+ return true
+ }
+
+ reset() {
+ const { done } = this
+ this.done = false
+ if (is.num(this._value)) {
+ this.elapsedTime = 0
+ this.lastPosition = this._value
+ if (done) this.lastVelocity = null
+ this.v0 = null
+ }
+ }
+}
diff --git a/packages/animated/src/context.ts b/packages/animated/src/context.ts
new file mode 100644
index 0000000000..57ae0436b6
--- /dev/null
+++ b/packages/animated/src/context.ts
@@ -0,0 +1,11 @@
+import { FluidValue } from '@react-spring/shared'
+
+export type TreeContext = {
+ /**
+ * Any animated values found when updating the payload of an `AnimatedObject`
+ * are also added to this `Set` to be observed by an animated component.
+ */
+ dependencies: Set | null
+}
+
+export const TreeContext: TreeContext = { dependencies: null }
diff --git a/packages/animated/src/createHost.ts b/packages/animated/src/createHost.ts
new file mode 100644
index 0000000000..96b230fcdf
--- /dev/null
+++ b/packages/animated/src/createHost.ts
@@ -0,0 +1,73 @@
+import { Lookup } from '@react-spring/types'
+import { is, eachProp } from '@react-spring/shared'
+import { AnimatableComponent, withAnimated } from './withAnimated'
+import { Animated } from './Animated'
+import { AnimatedObject } from './AnimatedObject'
+
+export interface HostConfig {
+ /** Provide custom logic for native updates */
+ applyAnimatedValues: (node: any, props: Lookup) => boolean | void
+ /** Wrap the `style` prop with an animated node */
+ createAnimatedStyle: (style: Lookup) => Animated
+ /** Intercept props before they're passed to an animated component */
+ getComponentProps: (props: Lookup) => typeof props
+}
+
+// A stub type that gets replaced by @react-spring/web and others.
+type WithAnimated = {
+ (Component: AnimatableComponent): any
+ [key: string]: any
+}
+
+// For storing the animated version on the original component
+const cacheKey = Symbol.for('AnimatedComponent')
+
+export const createHost = (
+ components: AnimatableComponent[] | { [key: string]: AnimatableComponent },
+ {
+ applyAnimatedValues = () => false,
+ createAnimatedStyle = style => new AnimatedObject(style),
+ getComponentProps = props => props,
+ }: Partial = {}
+) => {
+ const hostConfig: HostConfig = {
+ applyAnimatedValues,
+ createAnimatedStyle,
+ getComponentProps,
+ }
+
+ const animated: WithAnimated = (Component: any) => {
+ const displayName = getDisplayName(Component) || 'Anonymous'
+
+ if (is.str(Component)) {
+ Component =
+ animated[Component] ||
+ (animated[Component] = withAnimated(Component, hostConfig))
+ } else {
+ Component =
+ Component[cacheKey] ||
+ (Component[cacheKey] = withAnimated(Component, hostConfig))
+ }
+
+ Component.displayName = `Animated(${displayName})`
+ return Component
+ }
+
+ eachProp(components, (Component, key) => {
+ if (is.arr(components)) {
+ key = getDisplayName(Component)!
+ }
+ animated[key] = animated(Component)
+ })
+
+ return {
+ animated,
+ }
+}
+
+const getDisplayName = (arg: AnimatableComponent) =>
+ is.str(arg)
+ ? arg
+ : arg && is.str(arg.displayName)
+ ? arg.displayName
+ : (is.fun(arg) && arg.name) || null
diff --git a/packages/animated/src/getAnimatedType.ts b/packages/animated/src/getAnimatedType.ts
new file mode 100644
index 0000000000..7d712a067d
--- /dev/null
+++ b/packages/animated/src/getAnimatedType.ts
@@ -0,0 +1,18 @@
+import { is, isAnimatedString } from '@react-spring/shared'
+import { AnimatedType } from './types'
+import { AnimatedArray } from './AnimatedArray'
+import { AnimatedString } from './AnimatedString'
+import { AnimatedValue } from './AnimatedValue'
+import { getAnimated } from './Animated'
+
+/** Return the `Animated` node constructor for a given value */
+export function getAnimatedType(value: any): AnimatedType {
+ const parentNode = getAnimated(value)
+ return parentNode
+ ? (parentNode.constructor as any)
+ : is.arr(value)
+ ? AnimatedArray
+ : isAnimatedString(value)
+ ? AnimatedString
+ : AnimatedValue
+}
diff --git a/packages/animated/src/index.ts b/packages/animated/src/index.ts
new file mode 100644
index 0000000000..bc292797bd
--- /dev/null
+++ b/packages/animated/src/index.ts
@@ -0,0 +1,8 @@
+export * from './Animated'
+export * from './AnimatedValue'
+export * from './AnimatedString'
+export * from './AnimatedArray'
+export * from './AnimatedObject'
+export * from './getAnimatedType'
+export * from './createHost'
+export * from './types'
diff --git a/packages/animated/src/types.ts b/packages/animated/src/types.ts
new file mode 100644
index 0000000000..04d086d41e
--- /dev/null
+++ b/packages/animated/src/types.ts
@@ -0,0 +1,11 @@
+import { AnimatedArray } from './AnimatedArray'
+import { AnimatedValue } from './AnimatedValue'
+
+export type AnimatedType = Function & {
+ create: (
+ from: any,
+ goal?: any
+ ) => T extends ReadonlyArray
+ ? AnimatedArray
+ : AnimatedValue
+}
diff --git a/packages/animated/src/withAnimated.tsx b/packages/animated/src/withAnimated.tsx
new file mode 100644
index 0000000000..cce57d3c7f
--- /dev/null
+++ b/packages/animated/src/withAnimated.tsx
@@ -0,0 +1,130 @@
+import * as React from 'react'
+import { forwardRef, useRef, Ref, useCallback, useEffect } from 'react'
+import { useLayoutEffect } from 'react-layout-effect'
+import {
+ is,
+ each,
+ raf,
+ useForceUpdate,
+ useOnce,
+ FluidEvent,
+ FluidValue,
+ addFluidObserver,
+ removeFluidObserver,
+} from '@react-spring/shared'
+import { ElementType } from '@react-spring/types'
+
+import { AnimatedObject } from './AnimatedObject'
+import { TreeContext } from './context'
+import { HostConfig } from './createHost'
+
+export type AnimatableComponent = string | Exclude
+
+export const withAnimated = (Component: any, host: HostConfig) => {
+ const hasInstance: boolean =
+ // Function components must use "forwardRef" to avoid being
+ // re-rendered on every animation frame.
+ !is.fun(Component) ||
+ (Component.prototype && Component.prototype.isReactComponent)
+
+ return forwardRef((givenProps: any, givenRef: Ref) => {
+ const instanceRef = useRef(null)
+
+ // The `hasInstance` value is constant, so we can safely avoid
+ // the `useCallback` invocation when `hasInstance` is false.
+ const ref =
+ hasInstance &&
+ useCallback(
+ (value: any) => {
+ instanceRef.current = updateRef(givenRef, value)
+ },
+ [givenRef]
+ )
+
+ const [props, deps] = getAnimatedState(givenProps, host)
+
+ const forceUpdate = useForceUpdate()
+
+ const callback = () => {
+ const instance = instanceRef.current
+ if (hasInstance && !instance) {
+ // Either this component was unmounted before changes could be
+ // applied, or the wrapped component forgot to forward its ref.
+ return
+ }
+
+ const didUpdate = instance
+ ? host.applyAnimatedValues(instance, props.getValue(true))
+ : false
+
+ // Re-render the component when native updates fail.
+ if (didUpdate === false) {
+ forceUpdate()
+ }
+ }
+
+ const observer = new PropsObserver(callback, deps)
+
+ const observerRef = useRef()
+ useLayoutEffect(() => {
+ const lastObserver = observerRef.current
+ observerRef.current = observer
+
+ // Observe the latest dependencies.
+ each(deps, dep => addFluidObserver(dep, observer))
+
+ // Stop observing previous dependencies.
+ if (lastObserver) {
+ each(lastObserver.deps, dep => removeFluidObserver(dep, lastObserver))
+ raf.cancel(lastObserver.update)
+ }
+ })
+
+ useEffect(callback, [])
+ // Stop observing on unmount.
+ useOnce(() => () => {
+ const observer = observerRef.current!
+ each(observer.deps, dep => removeFluidObserver(dep, observer))
+ })
+
+ const usedProps = host.getComponentProps(props.getValue())
+ return
+ })
+}
+
+class PropsObserver {
+ constructor(readonly update: () => void, readonly deps: Set) {}
+ eventObserved(event: FluidEvent) {
+ if (event.type == 'change') {
+ raf.write(this.update)
+ }
+ }
+}
+
+type AnimatedState = [props: AnimatedObject, dependencies: Set]
+
+function getAnimatedState(props: any, host: HostConfig): AnimatedState {
+ const dependencies = new Set()
+ TreeContext.dependencies = dependencies
+
+ // Search the style for dependencies.
+ if (props.style)
+ props = {
+ ...props,
+ style: host.createAnimatedStyle(props.style),
+ }
+
+ // Search the props for dependencies.
+ props = new AnimatedObject(props)
+
+ TreeContext.dependencies = null
+ return [props, dependencies]
+}
+
+function updateRef(ref: Ref, value: T) {
+ if (ref) {
+ if (is.fun(ref)) ref(value)
+ else (ref as any).current = value
+ }
+ return value
+}
diff --git a/packages/animated/tsconfig.json b/packages/animated/tsconfig.json
new file mode 100644
index 0000000000..1ae07df09c
--- /dev/null
+++ b/packages/animated/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "forceConsistentCasingInFileNames": true,
+ "jsx": "react",
+ "lib": ["es2017"],
+ "moduleResolution": "node",
+ "noEmitOnError": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "preserveSymlinks": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "esnext",
+ "typeRoots": ["../../node_modules/@types"]
+ }
+}
diff --git a/packages/core/README.md b/packages/core/README.md
new file mode 100644
index 0000000000..b1ae0b7d48
--- /dev/null
+++ b/packages/core/README.md
@@ -0,0 +1,3 @@
+# @react-spring/core
+
+The platform-agnostic core of `react-spring`
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000000..7df89cdb2d
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@react-spring/core",
+ "version": "9.0.0-rc.4",
+ "main": "src/index.ts",
+ "scripts": {
+ "build": "rollup -c",
+ "postinstall": "node -e \"console.log('\\u001b[35m\\u001b[1mEnjoy react-spring? You can now donate to our open collective:\\u001b[22m\\u001b[39m\\n > \\u001b[34mhttps://opencollective.com/react-spring/donate\\u001b[0m')\""
+ },
+ "dependencies": {
+ "@react-spring/animated": "link:../animated",
+ "@react-spring/shared": "link:../shared",
+ "@react-spring/types": "link:../types",
+ "react-layout-effect": "^1.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ },
+ "publishConfig": {
+ "directory": "dist"
+ },
+ "devDependencies": {
+ "@testing-library/react": "^9.4.0",
+ "rafz": "link:../shared/node_modules/rafz",
+ "react-dom": "^16.12.0"
+ }
+}
diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js
new file mode 100644
index 0000000000..66cee09cf3
--- /dev/null
+++ b/packages/core/rollup.config.js
@@ -0,0 +1,3 @@
+import { bundle } from '../../rollup.config'
+
+export default bundle()
diff --git a/packages/core/src/Animation.ts b/packages/core/src/Animation.ts
new file mode 100644
index 0000000000..7b51097dce
--- /dev/null
+++ b/packages/core/src/Animation.ts
@@ -0,0 +1,22 @@
+import { AnimatedValue } from '@react-spring/animated'
+import { FluidValue } from '@react-spring/shared'
+import { AnimationConfig } from './AnimationConfig'
+import { PickEventFns } from './types/internal'
+import { SpringProps } from './types'
+
+const emptyArray: readonly any[] = []
+
+/** An animation being executed by the frameloop */
+export class Animation {
+ changed = false
+ values: readonly AnimatedValue[] = emptyArray
+ toValues: readonly number[] | null = null
+ fromValues: readonly number[] = emptyArray
+
+ to!: T | FluidValue
+ from!: T | FluidValue
+ config = new AnimationConfig()
+ immediate = false
+}
+
+export interface Animation extends PickEventFns> {}
diff --git a/packages/core/src/AnimationConfig.test.ts b/packages/core/src/AnimationConfig.test.ts
new file mode 100644
index 0000000000..5c182641e3
--- /dev/null
+++ b/packages/core/src/AnimationConfig.test.ts
@@ -0,0 +1,101 @@
+import { AnimationConfig, mergeConfig } from './AnimationConfig'
+
+const expo = (t: number) => Math.pow(t, 2)
+
+describe('mergeConfig', () => {
+ it('can merge partial configs', () => {
+ let config = new AnimationConfig()
+ mergeConfig(config, { tension: 0 })
+ mergeConfig(config, { friction: 0 })
+ expect(config).toMatchObject({
+ tension: 0,
+ friction: 0,
+ })
+
+ config = new AnimationConfig()
+ mergeConfig(config, { frequency: 2 })
+ mergeConfig(config, { damping: 0 })
+ expect(config).toMatchObject({
+ frequency: 2,
+ damping: 0,
+ })
+
+ config = new AnimationConfig()
+ mergeConfig(config, { duration: 2000 })
+ mergeConfig(config, { easing: expo })
+ expect(config).toMatchObject({
+ duration: 2000,
+ easing: expo,
+ })
+ })
+
+ it('resets the "duration" when props are incompatible', () => {
+ const config = new AnimationConfig()
+
+ mergeConfig(config, { duration: 1000 })
+ expect(config.duration).toBeDefined()
+
+ mergeConfig(config, { decay: 0.998 })
+ expect(config.duration).toBeUndefined()
+ expect(config.decay).toBe(0.998)
+
+ mergeConfig(config, { duration: 1000 })
+ expect(config.duration).toBeDefined()
+
+ mergeConfig(config, { frequency: 0.5 })
+ expect(config.duration).toBeUndefined()
+ expect(config.frequency).toBe(0.5)
+ })
+
+ it('resets the "decay" when props are incompatible', () => {
+ const config = new AnimationConfig()
+
+ mergeConfig(config, { decay: 0.998 })
+ expect(config.decay).toBeDefined()
+
+ mergeConfig(config, { mass: 2 })
+ expect(config.decay).toBeUndefined()
+ expect(config.mass).toBe(2)
+ })
+
+ it('resets the "frequency" when props are incompatible', () => {
+ const config = new AnimationConfig()
+
+ mergeConfig(config, { frequency: 0.5 })
+ expect(config.frequency).toBeDefined()
+
+ mergeConfig(config, { tension: 0 })
+ expect(config.frequency).toBeUndefined()
+ expect(config.tension).toBe(0)
+ })
+
+ describe('frequency/damping props', () => {
+ it('properly converts to tension/friction', () => {
+ const config = new AnimationConfig()
+ const merged = mergeConfig(config, { frequency: 0.5, damping: 1 })
+ expect(merged.tension).toBe(157.91367041742973)
+ expect(merged.friction).toBe(25.132741228718345)
+ })
+
+ it('works with extreme but valid values', () => {
+ const config = new AnimationConfig()
+ const merged = mergeConfig(config, { frequency: 2.6, damping: 0.1 })
+ expect(merged.tension).toBe(5.840002604194885)
+ expect(merged.friction).toBe(0.483321946706122)
+ })
+
+ it('prevents a damping ratio less than 0', () => {
+ const config = new AnimationConfig()
+ const validConfig = mergeConfig(config, { frequency: 0.5, damping: 0 })
+ const invalidConfig = mergeConfig(config, { frequency: 0.5, damping: -1 })
+ expect(invalidConfig).toMatchObject(validConfig)
+ })
+
+ it('prevents a frequency response less than 0.01', () => {
+ const config = new AnimationConfig()
+ const validConfig = mergeConfig(config, { frequency: 0.01, damping: 1 })
+ const invalidConfig = mergeConfig(config, { frequency: 0, damping: 1 })
+ expect(invalidConfig).toMatchObject(validConfig)
+ })
+ })
+})
diff --git a/packages/core/src/AnimationConfig.ts b/packages/core/src/AnimationConfig.ts
new file mode 100644
index 0000000000..913a74b22f
--- /dev/null
+++ b/packages/core/src/AnimationConfig.ts
@@ -0,0 +1,203 @@
+import { is } from '@react-spring/shared'
+import { config as configs } from './constants'
+
+const linear = (t: number) => t
+const defaults: any = {
+ ...configs.default,
+ mass: 1,
+ damping: 1,
+ easing: linear,
+ clamp: false,
+}
+
+export class AnimationConfig {
+ /**
+ * With higher tension, the spring will resist bouncing and try harder to stop at its end value.
+ *
+ * When tension is zero, no animation occurs.
+ */
+ tension!: number
+
+ /**
+ * The damping ratio coefficient, or just the damping ratio when `speed` is defined.
+ *
+ * When `speed` is defined, this value should be between 0 and 1.
+ *
+ * Higher friction means the spring will slow down faster.
+ */
+ friction!: number
+
+ /**
+ * The natural frequency (in seconds), which dictates the number of bounces
+ * per second when no damping exists.
+ *
+ * When defined, `tension` is derived from this, and `friction` is derived
+ * from `tension` and `damping`.
+ */
+ frequency?: number
+
+ /**
+ * The damping ratio, which dictates how the spring slows down.
+ *
+ * Set to `0` to never slow down. Set to `1` to slow down without bouncing.
+ * Between `0` and `1` is for you to explore.
+ *
+ * Only works when `frequency` is defined.
+ *
+ * Defaults to 1
+ */
+ damping!: number
+
+ /**
+ * Higher mass means more friction is required to slow down.
+ *
+ * Defaults to 1, which works fine most of the time.
+ */
+ mass!: number
+
+ /**
+ * The initial velocity of one or more values.
+ */
+ velocity: number | number[] = 0
+
+ /**
+ * The smallest velocity before the animation is considered "not moving".
+ *
+ * When undefined, `precision` is used instead.
+ */
+ restVelocity?: number
+
+ /**
+ * The smallest distance from a value before that distance is essentially zero.
+ *
+ * This helps in deciding when a spring is "at rest". The spring must be within
+ * this distance from its final value, and its velocity must be lower than this
+ * value too (unless `restVelocity` is defined).
+ */
+ precision?: number
+
+ /**
+ * For `duration` animations only. Note: The `duration` is not affected
+ * by this property.
+ *
+ * Defaults to `0`, which means "start from the beginning".
+ *
+ * Setting to `1+` makes an immediate animation.
+ *
+ * Setting to `0.5` means "start from the middle of the easing function".
+ *
+ * Any number `>= 0` and `<= 1` makes sense here.
+ */
+ progress?: number
+
+ /**
+ * Animation length in number of milliseconds.
+ */
+ duration?: number
+
+ /**
+ * The animation curve. Only used when `duration` is defined.
+ *
+ * Defaults to quadratic ease-in-out.
+ */
+ easing!: (t: number) => number
+
+ /**
+ * Avoid overshooting by ending abruptly at the goal value.
+ */
+ clamp!: boolean
+
+ /**
+ * When above zero, the spring will bounce instead of overshooting when
+ * exceeding its goal value. Its velocity is multiplied by `-1 + bounce`
+ * whenever its current value equals or exceeds its goal. For example,
+ * setting `bounce` to `0.5` chops the velocity in half on each bounce,
+ * in addition to any friction.
+ */
+ bounce?: number
+
+ /**
+ * "Decay animations" decelerate without an explicit goal value.
+ * Useful for scrolling animations.
+ *
+ * Use `true` for the default exponential decay factor (`0.998`).
+ *
+ * When a `number` between `0` and `1` is given, a lower number makes the
+ * animation slow down faster. And setting to `1` would make an unending
+ * animation.
+ */
+ decay?: boolean | number
+
+ /**
+ * While animating, round to the nearest multiple of this number.
+ * The `from` and `to` values are never rounded, as well as any value
+ * passed to the `set` method of an animated value.
+ */
+ round?: number
+
+ constructor() {
+ Object.assign(this, defaults)
+ }
+}
+
+export function mergeConfig(
+ config: AnimationConfig,
+ newConfig: Partial,
+ defaultConfig?: Partial
+): typeof config
+
+export function mergeConfig(
+ config: any,
+ newConfig: object,
+ defaultConfig?: object
+) {
+ if (defaultConfig) {
+ defaultConfig = { ...defaultConfig }
+ sanitizeConfig(defaultConfig, newConfig)
+ newConfig = { ...defaultConfig, ...newConfig }
+ }
+
+ sanitizeConfig(config, newConfig)
+ Object.assign(config, newConfig)
+
+ for (const key in defaults) {
+ if (config[key] == null) {
+ config[key] = defaults[key]
+ }
+ }
+
+ let { mass, frequency, damping } = config
+ if (!is.und(frequency)) {
+ if (frequency < 0.01) frequency = 0.01
+ if (damping < 0) damping = 0
+ config.tension = Math.pow((2 * Math.PI) / frequency, 2) * mass
+ config.friction = (4 * Math.PI * damping * mass) / frequency
+ }
+
+ return config
+}
+
+// Prevent a config from accidentally overriding new props.
+// This depends on which "config" props take precedence when defined.
+function sanitizeConfig(
+ config: Partial,
+ props: Partial
+) {
+ if (!is.und(props.decay)) {
+ config.duration = undefined
+ } else {
+ const isTensionConfig = !is.und(props.tension) || !is.und(props.friction)
+ if (
+ isTensionConfig ||
+ !is.und(props.frequency) ||
+ !is.und(props.damping) ||
+ !is.und(props.mass)
+ ) {
+ config.duration = undefined
+ config.decay = undefined
+ }
+ if (isTensionConfig) {
+ config.frequency = undefined
+ }
+ }
+}
diff --git a/packages/core/src/AnimationResult.ts b/packages/core/src/AnimationResult.ts
new file mode 100644
index 0000000000..157f514eb5
--- /dev/null
+++ b/packages/core/src/AnimationResult.ts
@@ -0,0 +1,48 @@
+import { AnimationResult } from './types'
+import { Readable } from './types/internal'
+
+/** @internal */
+export const getCombinedResult = (
+ target: T,
+ results: AnimationResult[]
+): AnimationResult =>
+ results.length == 1
+ ? results[0]
+ : results.some(result => result.cancelled)
+ ? getCancelledResult(target)
+ : results.every(result => result.noop)
+ ? getNoopResult(target)
+ : getFinishedResult(
+ target,
+ results.every(result => result.finished)
+ )
+
+/** No-op results are for updates that never start an animation. */
+export const getNoopResult = (
+ target: T,
+ value = target.get()
+) => ({
+ value,
+ noop: true,
+ finished: true,
+ target,
+})
+
+export const getFinishedResult = (
+ target: T,
+ finished: boolean,
+ value = target.get()
+) => ({
+ value,
+ finished,
+ target,
+})
+
+export const getCancelledResult = (
+ target: T,
+ value = target.get()
+) => ({
+ value,
+ cancelled: true,
+ target,
+})
diff --git a/packages/core/src/Controller.test.ts b/packages/core/src/Controller.test.ts
new file mode 100644
index 0000000000..25cf84347c
--- /dev/null
+++ b/packages/core/src/Controller.test.ts
@@ -0,0 +1,510 @@
+import { Controller } from './Controller'
+import { flushMicroTasks } from 'flush-microtasks'
+
+const frameLength = 1000 / 60
+
+describe('Controller', () => {
+ it('can animate a number', async () => {
+ const ctrl = new Controller({ x: 0 })
+ ctrl.start({ x: 100 })
+
+ await advanceUntilIdle()
+ const frames = getFrames(ctrl)
+ expect(frames).toMatchSnapshot()
+
+ // The first frame should *not* be the from value.
+ expect(frames[0]).not.toEqual({ x: 0 })
+
+ // The last frame should be the goal value.
+ expect(frames.slice(-1)[0]).toEqual({ x: 100 })
+ })
+
+ it('can animate an array of numbers', async () => {
+ const config = { precision: 0.005 }
+ const ctrl = new Controller<{ x: [number, number] }>({ x: [1, 2], config })
+ ctrl.start({ x: [5, 10] })
+
+ await advanceUntilIdle()
+ const frames = getFrames(ctrl)
+ expect(frames).toMatchSnapshot()
+
+ // The last frame should be the goal value.
+ expect(frames.slice(-1)[0]).toEqual({ x: [5, 10] })
+
+ // The 2nd value is always ~2x the 1st value (within the defined precision).
+ const factors = frames.map(frame => frame.x[1] / frame.x[0])
+ expect(
+ factors.every(factor => Math.abs(2 - factor) < config.precision)
+ ).toBeTruthy()
+ })
+
+ describe('when the "to" prop is an async function', () => {
+ it('respects the "cancel" prop', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+ const promise = ctrl.start({
+ to: async next => {
+ while (true) {
+ await next({ x: 1, reset: true })
+ }
+ },
+ })
+
+ const { x } = ctrl.springs
+ await advanceUntilValue(x, 0.5)
+
+ ctrl.start({ cancel: true })
+ await flushMicroTasks()
+
+ expect(ctrl.idle).toBeTruthy()
+ expect((await promise).cancelled).toBeTruthy()
+ })
+
+ it('respects the "stop" method', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+ const promise = ctrl.start({
+ to: async next => {
+ while (true) {
+ await next({ x: 1, reset: true })
+ }
+ },
+ })
+
+ const { x } = ctrl.springs
+ await advanceUntilValue(x, 0.5)
+
+ ctrl.stop()
+
+ expect(ctrl.idle).toBeTruthy()
+ expect((await promise).finished).toBeFalsy()
+ })
+
+ it('respects the "pause" prop', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+ ctrl.start({ pause: true })
+
+ let n = 0
+ ctrl.start({
+ to: async animate => {
+ while (true) {
+ n += 1
+ await animate({ x: 1, reset: true })
+ }
+ },
+ })
+
+ await flushMicroTasks()
+ expect(n).toBe(0)
+
+ ctrl.start({ pause: false })
+
+ await flushMicroTasks()
+ expect(n).toBe(1)
+ })
+
+ describe('when the "to" prop is changed', () => {
+ it('stops the old "to" prop', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+
+ let n = 0
+ const promise = ctrl.start({
+ to: async next => {
+ while (++n < 5) {
+ await next({ x: 1, reset: true })
+ }
+ },
+ })
+
+ await advance()
+ expect(n).toBe(1)
+
+ ctrl.start({
+ to: () => {},
+ })
+
+ await advanceUntilIdle()
+ expect(n).toBe(1)
+
+ expect(await promise).toMatchObject({
+ finished: false,
+ })
+ })
+ })
+
+ // This function is the "to" prop's 1st argument.
+ describe('the "animate" function', () => {
+ it('inherits any default props', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+ const onStart = jest.fn()
+ ctrl.start({
+ onStart,
+ to: async animate => {
+ expect(onStart).toBeCalledTimes(0)
+ await animate({ x: 1 })
+ expect(onStart).toBeCalledTimes(1)
+ await animate({ x: 0 })
+ },
+ })
+ await advanceUntilIdle()
+ expect(onStart).toBeCalledTimes(2)
+ })
+
+ it('can start its own async animation', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+
+ // Call this from inside the nested "to" prop.
+ const nestedFn = jest.fn()
+ // Call this after the nested "to" prop is done.
+ const afterFn = jest.fn()
+
+ ctrl.start({
+ to: async animate => {
+ await animate({
+ to: async animate => {
+ nestedFn()
+ await animate({ x: 1 })
+ },
+ })
+ afterFn()
+ },
+ })
+
+ await advanceUntilIdle()
+ await flushMicroTasks()
+
+ expect(nestedFn).toBeCalledTimes(1)
+ expect(afterFn).toBeCalledTimes(1)
+ })
+ })
+
+ describe('nested async animation', () => {
+ it('stops the parent on bail', async () => {
+ const ctrl = new Controller({ from: { x: 0 } })
+ const { x } = ctrl.springs
+
+ const afterFn = jest.fn()
+ ctrl.start({
+ to: async animate => {
+ await animate({
+ to: async animate => {
+ await animate({ x: 1 })
+ },
+ })
+ afterFn()
+ },
+ })
+
+ await advanceUntilValue(x, 0.5)
+ ctrl.start({ cancel: true })
+ await flushMicroTasks()
+
+ expect(ctrl.idle).toBeTruthy()
+ expect(afterFn).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('while paused', () => {
+ it('stays paused when its values are force-finished', () => {
+ const ctrl = new Controller<{ t: number }>({ t: 0 })
+ const { t } = ctrl.springs
+
+ const onRest = jest.fn()
+ ctrl.start({
+ to: next => next({ t: 1 }),
+ onRest,
+ })
+
+ mockRaf.step()
+ ctrl.pause()
+
+ t.finish()
+ mockRaf.step()
+
+ expect(ctrl['_state'].paused).toBeTruthy()
+ expect(onRest).not.toBeCalled()
+ })
+ })
+
+ it('acts strangely without the "from" prop', async () => {
+ const ctrl = new Controller<{ x: number }>()
+
+ const { springs } = ctrl
+ const promise = ctrl.start({
+ to: async update => {
+ // The spring does not exist yet!
+ expect(springs.x).toBeUndefined()
+
+ // Any values passed here are treated as "from" values,
+ // because no "from" prop was ever given.
+ const p1 = update({ x: 1 })
+ // Now the spring exists!
+ expect(springs.x).toBeDefined()
+ // But the spring is idle!
+ expect(springs.x.idle).toBeTruthy()
+
+ // This call *will* start an animation!
+ const p2 = update({ x: 2 })
+ expect(springs.x.idle).toBeFalsy()
+
+ await Promise.all([p1, p2])
+ },
+ })
+
+ await Promise.all([advanceUntilIdle(), promise])
+ expect(ctrl.idle).toBeTruthy()
+
+ // Since we call `update` twice, frames are generated!
+ expect(getFrames(ctrl)).toMatchSnapshot()
+ })
+ })
+
+ describe('when the "onStart" prop is defined', () => {
+ it('is called once per "start" call maximum', async () => {
+ const ctrl = new Controller({ x: 0, y: 0 })
+
+ const onStart = jest.fn()
+ ctrl.start({
+ x: 1,
+ y: 1,
+ onStart,
+ })
+
+ await advanceUntilIdle()
+ expect(onStart).toBeCalledTimes(1)
+ })
+
+ it('can be different per key', async () => {
+ const ctrl = new Controller({ x: 0, y: 0 })
+
+ const onStart1 = jest.fn()
+ ctrl.start({ x: 1, onStart: onStart1 })
+
+ const onStart2 = jest.fn()
+ ctrl.start({ y: 1, onStart: onStart2 })
+
+ await advanceUntilIdle()
+ expect(onStart1).toBeCalledTimes(1)
+ expect(onStart2).toBeCalledTimes(1)
+ })
+ })
+
+ describe('the "loop" prop', () => {
+ it('can be combined with the "reverse" prop', async () => {
+ const ctrl = new Controller({
+ t: 1,
+ from: { t: 0 },
+ config: { duration: frameLength * 3 },
+ })
+
+ const { t } = ctrl.springs
+ expect(t.get()).toBe(0)
+
+ await advanceUntilIdle()
+ expect(t.get()).toBe(1)
+
+ ctrl.start({
+ loop: { reverse: true },
+ })
+
+ await advanceUntilValue(t, 0)
+ await advanceUntilValue(t, 1)
+ expect(getFrames(t)).toMatchSnapshot()
+ })
+
+ describe('used with multiple values', () => {
+ it('loops all values at the same time', async () => {
+ const ctrl = new Controller()
+
+ ctrl.start({
+ to: { x: 1, y: 1 },
+ from: { x: 0, y: 0 },
+ config: key => ({ frequency: key == 'x' ? 0.3 : 1 }),
+ loop: true,
+ })
+
+ const { x, y } = ctrl.springs
+ for (let i = 0; i < 2; i++) {
+ await advanceUntilValue(y, 1)
+
+ // Both values should equal their "from" value at the same time.
+ expect(x.get()).toBe(x.animation.from)
+ expect(y.get()).toBe(y.animation.from)
+ }
+ })
+ })
+
+ describe('used when "to" is', () => {
+ describe('an async function', () => {
+ it('calls the "to" function repeatedly', async () => {
+ const ctrl = new Controller({ t: 0 })
+ const { t } = ctrl.springs
+
+ let loop = true
+ let times = 2
+
+ // Note: This example is silly, since you could use a for-loop
+ // to more easily achieve the same result, but it tests the ability
+ // to halt a looping script via the "loop" function prop.
+ ctrl.start({
+ loop: () => loop,
+ to: async next => {
+ await next({ t: 1 })
+ await next({ t: 0 })
+
+ if (times--) return
+ loop = false
+ },
+ })
+
+ await advanceUntilValue(t, 1)
+ expect(t.idle).toBeFalsy()
+
+ for (let i = 0; i < 2; i++) {
+ await advanceUntilValue(t, 0)
+ expect(t.idle).toBeFalsy()
+
+ await advanceUntilValue(t, 1)
+ expect(t.idle).toBeFalsy()
+ }
+
+ await advanceUntilValue(t, 0)
+ expect(t.idle).toBeTruthy()
+ })
+ })
+
+ describe('an array', () => {
+ it('repeats the chain of updates', async () => {
+ const ctrl = new Controller({ t: 0 })
+ const { t } = ctrl.springs
+
+ let loop = true
+ const promise = ctrl.start({
+ loop: () => {
+ return loop
+ },
+ from: { t: 0 },
+ to: [{ t: 1 }, { t: 2 }],
+ config: { duration: 3000 / 60 },
+ })
+
+ for (let i = 0; i < 3; i++) {
+ await advanceUntilValue(t, 2)
+ expect(t.idle).toBeFalsy()
+
+ // Run the first frame of the next loop.
+ mockRaf.step()
+ }
+
+ loop = false
+
+ await advanceUntilValue(t, 2)
+ expect(t.idle).toBeTruthy()
+
+ expect(await promise).toMatchObject({
+ value: { t: 2 },
+ finished: true,
+ })
+ })
+ })
+ })
+
+ describe('used on a noop update', () => {
+ it('does not loop', async () => {
+ const ctrl = new Controller({ t: 0 })
+
+ const loop = jest.fn(() => true)
+ ctrl.start({ t: 0, loop })
+
+ await advanceUntilIdle()
+ expect(loop).toBeCalledTimes(0)
+ })
+ })
+
+ describe('when "finish" is called while paused', () => {
+ async function getPausedLoop() {
+ const ctrl = new Controller<{ t: number }>({
+ from: { t: 0 }, // FIXME: replace this line with `t: 0,` for a stack overflow
+ loop: {
+ async to(start) {
+ await start({
+ t: 1,
+ reset: true,
+ })
+ },
+ },
+ })
+
+ // Ensure `loop.to` has been called.
+ await flushMicroTasks()
+
+ // Apply the first frame.
+ mockRaf.step()
+
+ ctrl.pause()
+ return ctrl
+ }
+
+ it('finishes immediately', async () => {
+ const ctrl = await getPausedLoop()
+ const { t } = ctrl.springs
+
+ expect(t.get()).toBeLessThan(1)
+ t.finish()
+ expect(t.get()).toBe(1)
+ })
+
+ it('does not loop until resumed', async () => {
+ const ctrl = await getPausedLoop()
+ const { t } = ctrl.springs
+
+ t.finish()
+ expect(t.idle).toBeTruthy()
+ expect(t.get()).toBe(1)
+
+ // HACK: The internal promise is undefined once resolved.
+ const expectResolved = (isResolved: boolean) =>
+ !ctrl['_state'].promise == isResolved
+
+ // Resume the paused loop.
+ ctrl.resume()
+
+ // Its promise is not resolved yet..
+ expectResolved(false)
+ expect(t.idle).toBeTruthy()
+ expect(t.get()).toBe(1)
+
+ // ..but in the next microtask, it will be..
+ await flushMicroTasks()
+ expectResolved(true)
+ // ..which means the loop restarts!
+ expect(t.idle).toBeFalsy()
+ expect(t.get()).toBe(0)
+ })
+ })
+ })
+
+ describe('the "stop" method', () => {
+ it('prevents any updates with pending delays', async () => {
+ const ctrl = new Controller<{ t: number }>({ t: 0 })
+ const { t } = ctrl.springs
+
+ ctrl.start({ t: 1, delay: 100 })
+ ctrl.stop()
+
+ await advanceUntilIdle()
+ expect(ctrl['_state'].timeouts.size).toBe(0)
+ expect(t['_state'].timeouts.size).toBe(0)
+ })
+
+ it('stops the active runAsync call', async () => {
+ const ctrl = new Controller<{ t: number }>({ t: 0 })
+ ctrl.start({
+ to: async animate => {
+ await animate({ t: 1 })
+ },
+ })
+ ctrl.stop()
+ await advanceUntilIdle()
+ expect(ctrl['_state'].asyncTo).toBeUndefined()
+ })
+ })
+})
diff --git a/packages/core/src/Controller.ts b/packages/core/src/Controller.ts
new file mode 100644
index 0000000000..21f785afac
--- /dev/null
+++ b/packages/core/src/Controller.ts
@@ -0,0 +1,498 @@
+import { OneOrMore, UnknownProps, Lookup, Falsy } from '@react-spring/types'
+import {
+ is,
+ raf,
+ each,
+ noop,
+ flush,
+ toArray,
+ eachProp,
+ flushCalls,
+ addFluidObserver,
+ FluidObserver,
+} from '@react-spring/shared'
+
+import { getDefaultProp } from './helpers'
+import { FrameValue } from './FrameValue'
+import { SpringRef } from './SpringRef'
+import { SpringValue, createLoopUpdate, createUpdate } from './SpringValue'
+import { getCancelledResult, getCombinedResult } from './AnimationResult'
+import { runAsync, RunAsyncState, stopAsync } from './runAsync'
+import { scheduleProps } from './scheduleProps'
+import {
+ AnimationResult,
+ AsyncResult,
+ ControllerFlushFn,
+ ControllerUpdate,
+ OnRest,
+ SpringValues,
+} from './types'
+
+/** Events batched by the `Controller` class */
+const BATCHED_EVENTS = ['onStart', 'onChange', 'onRest'] as const
+
+let nextId = 1
+
+/** Queue of pending updates for a `Controller` instance. */
+export interface ControllerQueue
+ extends Array<
+ ControllerUpdate & {
+ /** The keys affected by this update. When null, all keys are affected. */
+ keys: string[] | null
+ }
+ > {}
+
+export class Controller {
+ readonly id = nextId++
+
+ /** The animated values */
+ springs: SpringValues = {} as any
+
+ /** The queue of props passed to the `update` method. */
+ queue: ControllerQueue = []
+
+ /**
+ * The injected ref. When defined, render-based updates are pushed
+ * onto the `queue` instead of being auto-started.
+ */
+ ref?: SpringRef
+
+ /** Custom handler for flushing update queues */
+ protected _flush?: ControllerFlushFn
+
+ /** These props are used by all future spring values */
+ protected _initialProps?: Lookup
+
+ /** The counter for tracking `scheduleProps` calls */
+ protected _lastAsyncId = 0
+
+ /** The values currently being animated */
+ protected _active = new Set()
+
+ /** The values that changed recently */
+ protected _changed = new Set()
+
+ /** Equals false when `onStart` listeners can be called */
+ protected _started = false
+
+ /** State used by the `runAsync` function */
+ protected _state: RunAsyncState = {
+ paused: false,
+ pauseQueue: new Set(),
+ resumeQueue: new Set(),
+ timeouts: new Set(),
+ }
+
+ /** The event queues that are flushed once per frame maximum */
+ protected _events = {
+ onStart: new Set<(ctrl: Controller) => void>(),
+ onChange: new Set<(values: object) => void>(),
+ onRest: new Map(),
+ }
+
+ constructor(
+ props?: ControllerUpdate | null,
+ flush?: ControllerFlushFn
+ ) {
+ this._onFrame = this._onFrame.bind(this)
+ if (flush) {
+ this._flush = flush
+ }
+ if (props) {
+ this.start({ default: true, ...props })
+ }
+ }
+
+ /**
+ * Equals `true` when no spring values are in the frameloop, and
+ * no async animation is currently active.
+ */
+ get idle() {
+ return (
+ !this._state.asyncTo &&
+ Object.values(this.springs as Lookup).every(
+ spring => spring.idle
+ )
+ )
+ }
+
+ /** Get the current values of our springs */
+ get(): State & UnknownProps {
+ const values: any = {}
+ this.each((spring, key) => (values[key] = spring.get()))
+ return values
+ }
+
+ /** Set the current values without animating. */
+ set(values: Partial) {
+ for (const key in values) {
+ const value = values[key]
+ if (!is.und(value)) {
+ this.springs[key].set(value)
+ }
+ }
+ }
+
+ /** Push an update onto the queue of each value. */
+ update(props: ControllerUpdate | Falsy) {
+ if (props) {
+ this.queue.push(createUpdate(props))
+ }
+ return this
+ }
+
+ /**
+ * Start the queued animations for every spring, and resolve the returned
+ * promise once all queued animations have finished or been cancelled.
+ *
+ * When you pass a queue (instead of nothing), that queue is used instead of
+ * the queued animations added with the `update` method, which are left alone.
+ */
+ start(props?: OneOrMore> | null): AsyncResult {
+ let { queue } = this as any
+ if (props) {
+ queue = toArray(props).map(createUpdate)
+ } else {
+ this.queue = []
+ }
+
+ if (this._flush) {
+ return this._flush(this, queue)
+ }
+
+ prepareKeys(this, queue)
+ return flushUpdateQueue(this, queue)
+ }
+
+ /** Stop all animations. */
+ stop(): this
+ /** Stop animations for the given keys. */
+ stop(keys: OneOrMore): this
+ /** Cancel all animations. */
+ stop(cancel: boolean): this
+ /** Cancel animations for the given keys. */
+ stop(cancel: boolean, keys: OneOrMore): this
+ /** Stop some or all animations. */
+ stop(keys?: OneOrMore): this
+ /** Cancel some or all animations. */
+ stop(cancel: boolean, keys?: OneOrMore): this
+ /** @internal */
+ stop(arg?: boolean | OneOrMore, keys?: OneOrMore) {
+ if (arg !== !!arg) {
+ keys = arg as OneOrMore
+ }
+ if (keys) {
+ const springs = this.springs as Lookup
+ each(toArray(keys), key => springs[key].stop(!!arg))
+ } else {
+ stopAsync(this._state, this._lastAsyncId)
+ this.each(spring => spring.stop(!!arg))
+ }
+ return this
+ }
+
+ /** Freeze the active animation in time */
+ pause(keys?: OneOrMore) {
+ if (is.und(keys)) {
+ this.start({ pause: true })
+ } else {
+ const springs = this.springs as Lookup
+ each(toArray(keys), key => springs[key].pause())
+ }
+ return this
+ }
+
+ /** Resume the animation if paused. */
+ resume(keys?: OneOrMore) {
+ if (is.und(keys)) {
+ this.start({ pause: false })
+ } else {
+ const springs = this.springs as Lookup
+ each(toArray(keys), key => springs[key].resume())
+ }
+ return this
+ }
+
+ /** Call a function once per spring value */
+ each(iterator: (spring: SpringValue, key: string) => void) {
+ eachProp(this.springs, iterator as any)
+ }
+
+ /** @internal Called at the end of every animation frame */
+ protected _onFrame() {
+ const { onStart, onChange, onRest } = this._events
+
+ const active = this._active.size > 0
+ if (active && !this._started) {
+ this._started = true
+ flushCalls(onStart, this)
+ }
+
+ const idle = !active && this._started
+ const changed = this._changed.size > 0 && onChange.size
+ const values = changed || (idle && onRest.size) ? this.get() : null
+
+ if (changed) {
+ flushCalls(onChange, values!)
+ }
+
+ // The "onRest" queue is only flushed when all springs are idle.
+ if (idle) {
+ this._started = false
+ flush(onRest, ([onRest, result]) => {
+ result.value = values
+ onRest(result)
+ })
+ }
+ }
+
+ /** @internal */
+ eventObserved(event: FrameValue.Event) {
+ if (event.type == 'change') {
+ this._changed.add(event.parent)
+ if (!event.idle) {
+ this._active.add(event.parent)
+ }
+ } else if (event.type == 'idle') {
+ this._active.delete(event.parent)
+ }
+ // The `onFrame` handler runs when a parent is changed or idle.
+ else return
+ raf.onFrame(this._onFrame)
+ }
+}
+
+/**
+ * Warning: Props might be mutated.
+ */
+export function flushUpdateQueue(
+ ctrl: Controller,
+ queue: ControllerQueue
+) {
+ return Promise.all(
+ queue.map(props => flushUpdate(ctrl, props))
+ ).then(results => getCombinedResult(ctrl, results))
+}
+
+/**
+ * Warning: Props might be mutated.
+ *
+ * Process a single set of props using the given controller.
+ *
+ * The returned promise resolves to `true` once the update is
+ * applied and any animations it starts are finished without being
+ * stopped or cancelled.
+ */
+export async function flushUpdate(
+ ctrl: Controller,
+ props: ControllerQueue[number],
+ isLoop?: boolean
+): AsyncResult {
+ const { keys, to, from, loop, onRest, onResolve } = props
+ const defaults = is.obj(props.default) && props.default
+
+ // Looping must be handled in this function, or else the values
+ // would end up looping out-of-sync in many common cases.
+ if (loop) {
+ props.loop = false
+ }
+
+ // Treat false like null, which gets ignored.
+ if (to === false) props.to = null
+ if (from === false) props.from = null
+
+ const asyncTo = is.arr(to) || is.fun(to) ? to : undefined
+ if (asyncTo) {
+ props.to = undefined
+ props.onRest = undefined
+ if (defaults) {
+ defaults.onRest = undefined
+ }
+ }
+ // For certain events, use batching to prevent multiple calls per frame.
+ // However, batching is avoided when the `to` prop is async, because any
+ // event props are used as default props instead.
+ else {
+ each(BATCHED_EVENTS, key => {
+ const handler: any = props[key]
+ if (is.fun(handler)) {
+ const queue = ctrl['_events'][key]
+ if (queue instanceof Set) {
+ props[key] = () => queue.add(handler)
+ } else {
+ props[key] = (({ finished, cancelled }: AnimationResult) => {
+ const result = queue.get(handler)
+ if (result) {
+ if (!finished) result.finished = false
+ if (cancelled) result.cancelled = true
+ } else {
+ // The "value" is set before the "handler" is called.
+ queue.set(handler, {
+ target: ctrl,
+ value: null,
+ finished,
+ cancelled,
+ })
+ }
+ }) as any
+ }
+ // Avoid using a batched `handler` as a default prop.
+ if (defaults) {
+ defaults[key] = props[key] as any
+ }
+ }
+ })
+ }
+
+ const state = ctrl['_state']
+
+ // Pause/resume the `asyncTo` when `props.pause` is true/false.
+ if (props.pause === !state.paused) {
+ state.paused = props.pause
+ flushCalls(props.pause ? state.pauseQueue : state.resumeQueue)
+ }
+ // When a controller is paused, its values are also paused.
+ else if (state.paused) {
+ props.pause = true
+ }
+
+ const promises: AsyncResult[] = (keys || Object.keys(ctrl.springs)).map(key =>
+ ctrl.springs[key]!.start(props as any)
+ )
+
+ const cancel =
+ props.cancel === true || getDefaultProp(props, 'cancel') === true
+
+ if (asyncTo || (cancel && state.asyncId)) {
+ promises.push(
+ scheduleProps(++ctrl['_lastAsyncId'], {
+ props,
+ state,
+ actions: {
+ pause: noop,
+ resume: noop,
+ start(props, resolve) {
+ if (cancel) {
+ stopAsync(state, ctrl['_lastAsyncId'])
+ resolve(getCancelledResult(ctrl))
+ } else {
+ props.onRest = onRest
+ resolve(runAsync(asyncTo!, props, state, ctrl))
+ }
+ },
+ },
+ })
+ )
+ }
+
+ // Pause after updating each spring, so they can be resumed separately
+ // and so their default `pause` and `cancel` props are updated.
+ if (state.paused) {
+ // Ensure `this` must be resumed before the returned promise
+ // is resolved and before starting the next `loop` repetition.
+ await new Promise(resume => {
+ state.resumeQueue.add(resume)
+ })
+ }
+
+ const result = getCombinedResult(ctrl, await Promise.all(promises))
+ if (loop && result.finished && !(isLoop && result.noop)) {
+ const nextProps = createLoopUpdate(props, loop, to)
+ if (nextProps) {
+ prepareKeys(ctrl, [nextProps])
+ return flushUpdate(ctrl, nextProps, true)
+ }
+ }
+ if (onResolve) {
+ raf.batchedUpdates(() => onResolve(result))
+ }
+ return result
+}
+
+/**
+ * From an array of updates, get the map of `SpringValue` objects
+ * by their keys. Springs are created when any update wants to
+ * animate a new key.
+ *
+ * Springs created by `getSprings` are neither cached nor observed
+ * until they're given to `setSprings`.
+ */
+export function getSprings(
+ ctrl: Controller,
+ props?: OneOrMore>
+) {
+ const springs = { ...ctrl.springs }
+ if (props) {
+ each(toArray(props), (props: any) => {
+ if (is.und(props.keys)) {
+ props = createUpdate(props)
+ }
+ if (!is.obj(props.to)) {
+ // Avoid passing array/function to each spring.
+ props = { ...props, to: undefined }
+ }
+ prepareSprings(springs as any, props, key => {
+ return createSpring(key)
+ })
+ })
+ }
+ return springs
+}
+
+/**
+ * Tell a controller to manage the given `SpringValue` objects
+ * whose key is not already in use.
+ */
+export function setSprings(
+ ctrl: Controller,
+ springs: SpringValues
+) {
+ eachProp(springs, (spring, key) => {
+ if (!ctrl.springs[key]) {
+ ctrl.springs[key] = spring
+ addFluidObserver(spring, ctrl)
+ }
+ })
+}
+
+function createSpring(key: string, observer?: FluidObserver) {
+ const spring = new SpringValue()
+ spring.key = key
+ if (observer) {
+ addFluidObserver(spring, observer)
+ }
+ return spring
+}
+
+/**
+ * Ensure spring objects exist for each defined key.
+ *
+ * Using the `props`, the `Animated` node of each `SpringValue` may
+ * be created or updated.
+ */
+function prepareSprings(
+ springs: SpringValues,
+ props: ControllerQueue[number],
+ create: (key: string) => SpringValue
+) {
+ if (props.keys) {
+ each(props.keys, key => {
+ const spring = springs[key] || (springs[key] = create(key))
+ spring['_prepareNode'](props)
+ })
+ }
+}
+
+/**
+ * Ensure spring objects exist for each defined key, and attach the
+ * `ctrl` to them for observation.
+ *
+ * The queue is expected to contain `createUpdate` results.
+ */
+function prepareKeys(ctrl: Controller, queue: ControllerQueue[number][]) {
+ each(queue, props => {
+ prepareSprings(ctrl.springs, props, key => {
+ return createSpring(key, ctrl)
+ })
+ })
+}
diff --git a/packages/core/src/FrameValue.ts b/packages/core/src/FrameValue.ts
new file mode 100644
index 0000000000..5a38e560db
--- /dev/null
+++ b/packages/core/src/FrameValue.ts
@@ -0,0 +1,132 @@
+import {
+ deprecateInterpolate,
+ frameLoop,
+ FluidValue,
+ Globals as G,
+ callFluidObservers,
+} from '@react-spring/shared'
+import { InterpolatorArgs } from '@react-spring/types'
+import { getAnimated } from '@react-spring/animated'
+
+import { Interpolation } from './Interpolation'
+
+export const isFrameValue = (value: any): value is FrameValue =>
+ value instanceof FrameValue
+
+let nextId = 1
+
+/**
+ * A kind of `FluidValue` that manages an `AnimatedValue` node.
+ *
+ * Its underlying value can be accessed and even observed.
+ */
+export abstract class FrameValue extends FluidValue<
+ T,
+ FrameValue.Event
+> {
+ readonly id = nextId++
+
+ abstract key?: string
+ abstract get idle(): boolean
+
+ protected _priority = 0
+
+ get priority() {
+ return this._priority
+ }
+ set priority(priority: number) {
+ if (this._priority != priority) {
+ this._priority = priority
+ this._onPriorityChange(priority)
+ }
+ }
+
+ /** Get the current value */
+ get(): T {
+ const node = getAnimated(this)
+ return node && node.getValue()
+ }
+
+ /** Create a spring that maps our value to another value */
+ to(...args: InterpolatorArgs) {
+ return G.to(this, args) as Interpolation
+ }
+
+ /** @deprecated Use the `to` method instead. */
+ interpolate(...args: InterpolatorArgs) {
+ deprecateInterpolate()
+ return G.to(this, args) as Interpolation
+ }
+
+ toJSON() {
+ return this.get()
+ }
+
+ protected observerAdded(count: number) {
+ if (count == 1) this._attach()
+ }
+
+ protected observerRemoved(count: number) {
+ if (count == 0) this._detach()
+ }
+
+ /** @internal */
+ abstract advance(dt: number): void
+
+ /** @internal */
+ abstract eventObserved(_event: FrameValue.Event): void
+
+ /** Called when the first child is added. */
+ protected _attach() {}
+
+ /** Called when the last child is removed. */
+ protected _detach() {}
+
+ /** Tell our children about our new value */
+ protected _onChange(value: T, idle = false) {
+ callFluidObservers(this, {
+ type: 'change',
+ parent: this,
+ value,
+ idle,
+ })
+ }
+
+ /** Tell our children about our new priority */
+ protected _onPriorityChange(priority: number) {
+ if (!this.idle) {
+ frameLoop.sort(this)
+ }
+ callFluidObservers(this, {
+ type: 'priority',
+ parent: this,
+ priority,
+ })
+ }
+}
+
+export declare namespace FrameValue {
+ /** A parent changed its value */
+ interface ChangeEvent {
+ parent: FrameValue
+ type: 'change'
+ value: T
+ idle: boolean
+ }
+
+ /** A parent changed its priority */
+ interface PriorityEvent {
+ parent: FrameValue
+ type: 'priority'
+ priority: number
+ }
+
+ /** A parent is done animating */
+ interface IdleEvent {
+ parent: FrameValue
+ type: 'idle'
+ }
+
+ /** Events sent to children of `FrameValue` objects */
+ export type Event = ChangeEvent | PriorityEvent | IdleEvent
+}
diff --git a/packages/core/src/Interpolation.test.ts b/packages/core/src/Interpolation.test.ts
new file mode 100644
index 0000000000..379746bc19
--- /dev/null
+++ b/packages/core/src/Interpolation.test.ts
@@ -0,0 +1,56 @@
+import { SpringValue } from './SpringValue'
+import { to } from './interpolate'
+import { addFluidObserver } from '@react-spring/shared'
+
+describe('Interpolation', () => {
+ it.todo('can use a SpringValue')
+ it.todo('can use another Interpolation')
+ it.todo('can use a non-animated FluidValue')
+
+ describe('when multiple inputs change in the same frame', () => {
+ it.todo('only computes its value once')
+ })
+
+ describe('when an input resets its animation', () => {
+ it.todo('computes its value before the first frame')
+ })
+
+ describe('when all inputs are paused', () => {
+ it('leaves the frameloop', () => {
+ const a = new SpringValue({ from: 0, to: 1 })
+ const b = new SpringValue({ from: 1, to: 0 })
+ mockRaf.step()
+
+ const calc = jest.fn((a: number, b: number) => Math.abs(a - b))
+ const c = to([a, b], calc)
+
+ // For interpolation to be active, it must be observed.
+ const observer = jest.fn()
+ addFluidObserver(c, observer)
+
+ // Pause the first input.
+ a.pause()
+
+ // Expect interpolation to continue.
+ calc.mockClear()
+ mockRaf.step()
+ expect(calc).toBeCalled()
+
+ // Pause the other input.
+ b.pause()
+
+ // In the next frame, the interpolation still calculates its next value.
+ // When its value stays the same, it checks the idle status of each input,
+ // which triggers an update to its own idle status.
+ calc.mockClear()
+ mockRaf.step()
+ expect(calc).toBeCalled()
+ expect(c.idle).toBeTruthy()
+
+ // Expect interpolation to be paused.
+ calc.mockClear()
+ mockRaf.step()
+ expect(calc).not.toBeCalled()
+ })
+ })
+})
diff --git a/packages/core/src/Interpolation.ts b/packages/core/src/Interpolation.ts
new file mode 100644
index 0000000000..d747d3cf3c
--- /dev/null
+++ b/packages/core/src/Interpolation.ts
@@ -0,0 +1,185 @@
+import { Arrify, InterpolatorArgs, InterpolatorFn } from '@react-spring/types'
+import {
+ is,
+ raf,
+ each,
+ isEqual,
+ toArray,
+ frameLoop,
+ FluidValue,
+ getFluidValue,
+ createInterpolator,
+ Globals as G,
+ callFluidObservers,
+ addFluidObserver,
+ removeFluidObserver,
+ hasFluidValue,
+} from '@react-spring/shared'
+
+import { FrameValue, isFrameValue } from './FrameValue'
+import {
+ getAnimated,
+ setAnimated,
+ getAnimatedType,
+ getPayload,
+} from '@react-spring/animated'
+
+/**
+ * An `Interpolation` is a memoized value that's computed whenever one of its
+ * `FluidValue` dependencies has its value changed.
+ *
+ * Other `FrameValue` objects can depend on this. For example, passing an
+ * `Interpolation` as the `to` prop of a `useSpring` call will trigger an
+ * animation toward the memoized value.
+ */
+export class Interpolation extends FrameValue {
+ /** Useful for debugging. */
+ key?: string
+
+ /** Equals false when in the frameloop */
+ idle = true
+
+ /** The function that maps inputs values to output */
+ readonly calc: InterpolatorFn
+
+ /** The inputs which are currently animating */
+ protected _active = new Set()
+
+ constructor(
+ /** The source of input values */
+ readonly source: unknown,
+ args: InterpolatorArgs
+ ) {
+ super()
+ this.calc = createInterpolator(...args)
+
+ const value = this._get()
+ const nodeType = getAnimatedType(value)
+
+ // Assume the computed value never changes type.
+ setAnimated(this, nodeType.create(value))
+ }
+
+ advance(_dt?: number) {
+ const value = this._get()
+ const oldValue = this.get()
+ if (!isEqual(value, oldValue)) {
+ getAnimated(this)!.setValue(value)
+ this._onChange(value, this.idle)
+ }
+ // Become idle when all parents are idle or paused.
+ if (!this.idle && checkIdle(this._active)) {
+ becomeIdle(this)
+ }
+ }
+
+ protected _get() {
+ const inputs: Arrify = is.arr(this.source)
+ ? this.source.map(getFluidValue)
+ : (toArray(getFluidValue(this.source)) as any)
+
+ return this.calc(...inputs)
+ }
+
+ protected _start() {
+ if (this.idle && !checkIdle(this._active)) {
+ this.idle = false
+
+ each(getPayload(this)!, node => {
+ node.done = false
+ })
+
+ if (G.skipAnimation) {
+ raf.batchedUpdates(() => this.advance())
+ becomeIdle(this)
+ } else {
+ frameLoop.start(this)
+ }
+ }
+ }
+
+ // Observe our sources only when we're observed.
+ protected _attach() {
+ let priority = 1
+ each(toArray(this.source), source => {
+ if (hasFluidValue(source)) {
+ addFluidObserver(source, this)
+ }
+ if (isFrameValue(source)) {
+ if (!source.idle) {
+ this._active.add(source)
+ }
+ priority = Math.max(priority, source.priority + 1)
+ }
+ })
+ this.priority = priority
+ this._start()
+ }
+
+ // Stop observing our sources once we have no observers.
+ protected _detach() {
+ each(toArray(this.source), source => {
+ if (hasFluidValue(source)) {
+ removeFluidObserver(source, this)
+ }
+ })
+ this._active.clear()
+ becomeIdle(this)
+ }
+
+ /** @internal */
+ eventObserved(event: FrameValue.Event) {
+ // Update our value when an idle parent is changed,
+ // and enter the frameloop when a parent is resumed.
+ if (event.type == 'change') {
+ if (event.idle) {
+ this.advance()
+ } else {
+ this._active.add(event.parent)
+ this._start()
+ }
+ }
+ // Once all parents are idle, the `advance` method runs one more time,
+ // so we should avoid updating the `idle` status here.
+ else if (event.type == 'idle') {
+ this._active.delete(event.parent)
+ }
+ // Ensure our priority is greater than all parents, which means
+ // our value won't be updated until our parents have updated.
+ else if (event.type == 'priority') {
+ this.priority = toArray(this.source).reduce(
+ (highest: number, parent) =>
+ Math.max(highest, (isFrameValue(parent) ? parent.priority : 0) + 1),
+ 0
+ )
+ }
+ }
+}
+
+/** Returns true for an idle source. */
+function isIdle(source: any) {
+ return source.idle !== false
+}
+
+/** Return true if all values in the given set are idle or paused. */
+function checkIdle(active: Set) {
+ // Parents can be active even when paused, so the `.every` check
+ // removes us from the frameloop if all active parents are paused.
+ return !active.size || Array.from(active).every(isIdle)
+}
+
+/** Become idle if not already idle. */
+function becomeIdle(self: Interpolation) {
+ if (!self.idle) {
+ self.idle = true
+
+ each(getPayload(self)!, node => {
+ node.done = true
+ })
+
+ callFluidObservers(self, {
+ type: 'idle',
+ parent: self,
+ })
+ }
+}
diff --git a/packages/core/src/SpringContext.test.tsx b/packages/core/src/SpringContext.test.tsx
new file mode 100644
index 0000000000..bd1713d606
--- /dev/null
+++ b/packages/core/src/SpringContext.test.tsx
@@ -0,0 +1,128 @@
+import * as React from 'react'
+import { render, RenderResult } from '@testing-library/react'
+import { SpringContext } from './SpringContext'
+import { SpringValue } from './SpringValue'
+import { useSpring } from './hooks'
+
+describe('SpringContext', () => {
+ let t: SpringValue
+
+ const Child = () => {
+ t = useSpring({ t: 1, from: { t: 0 } }).t
+ return null
+ }
+
+ const update = createUpdater(props => (
+
+
+
+ ))
+
+ it('only merges when changed', () => {
+ const context: SpringContext = {}
+ const onProps = jest.fn()
+ const Test = () => {
+ useSpring({ onProps, x: 0 })
+ return null
+ }
+
+ const getRoot = () => (
+
+
+
+ )
+
+ const expectUpdates = (updates: any[]) => {
+ onProps.mock.calls.forEach((args, i) => {
+ const update = updates[i]
+ if (update) {
+ expect(args[0]).toMatchObject(update)
+ } else {
+ // Unexpected update.
+ expect(args[0]).toBeUndefined()
+ }
+ })
+ onProps.mockClear()
+ }
+
+ const elem = render(getRoot())
+ expectUpdates([{ onProps, to: { x: 0 } }])
+
+ context.pause = true
+ elem.rerender(getRoot())
+ expectUpdates([{ default: context }, { onProps, to: { x: 0 } }])
+
+ elem.rerender(getRoot())
+ expectUpdates([{ onProps, to: { x: 0 } }])
+ })
+
+ it('can pause current animations', () => {
+ update({})
+ mockRaf.step()
+ expect(t.idle).toBeFalsy()
+
+ update({ pause: true })
+ expect(t.idle).toBeTruthy()
+ expect(t.goal).toBe(1)
+
+ update({ pause: false })
+ expect(t.idle).toBeFalsy()
+ expect(t.goal).toBe(1)
+ })
+ it('can pause future animations', () => {
+ // Paused right away.
+ update({ pause: true })
+ expect(t.idle).toBeTruthy()
+ expect(t.goal).toBeUndefined()
+
+ // This update is paused too.
+ t.start(2)
+ expect(t.idle).toBeTruthy()
+ expect(t.goal).toBeUndefined()
+
+ // Let it roll.
+ update({ pause: false })
+ expect(t.idle).toBeFalsy()
+ // The `goal` is not 2, because the `useSpring` hook is
+ // executed by the SpringContext update.
+ expect(t.goal).toBe(1)
+ })
+
+ it('can make current animations immediate', () => {
+ update({})
+ mockRaf.step()
+ expect(t.idle).toBeFalsy()
+
+ update({ immediate: true })
+ mockRaf.step()
+
+ expect(t.idle).toBeTruthy()
+ expect(t.get()).toBe(1)
+ })
+ it('can make future animations immediate', () => {
+ update({ immediate: true })
+ mockRaf.step()
+
+ expect(t.idle).toBeTruthy()
+ expect(t.get()).toBe(1)
+
+ t.start(2)
+ mockRaf.step()
+
+ expect(t.idle).toBeTruthy()
+ expect(t.get()).toBe(2)
+ })
+})
+
+function createUpdater(Component: React.ComponentType) {
+ let result: RenderResult | undefined
+ afterEach(() => {
+ result = undefined
+ })
+ return (props: SpringContext) => {
+ const elem =
+ if (result) result.rerender(elem)
+ else result = render(elem)
+ return result
+ }
+}
diff --git a/packages/core/src/SpringContext.tsx b/packages/core/src/SpringContext.tsx
new file mode 100644
index 0000000000..8e0a48d6fe
--- /dev/null
+++ b/packages/core/src/SpringContext.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react'
+import { useContext, PropsWithChildren } from 'react'
+import { useMemoOne } from '@react-spring/shared'
+
+/**
+ * This context affects all new and existing `SpringValue` objects
+ * created with the hook API or the renderprops API.
+ */
+export interface SpringContext {
+ /** Pause all new and existing animations. */
+ pause?: boolean
+ /** Force all new and existing animations to be immediate. */
+ immediate?: boolean
+}
+
+export const SpringContext = ({
+ children,
+ ...props
+}: PropsWithChildren) => {
+ const inherited = useContext(ctx)
+
+ // Inherited values are dominant when truthy.
+ const pause = props.pause || !!inherited.pause,
+ immediate = props.immediate || !!inherited.immediate
+
+ // Memoize the context to avoid unwanted renders.
+ props = useMemoOne(() => ({ pause, immediate }), [pause, immediate])
+
+ const { Provider } = ctx
+ return {children}
+}
+
+const ctx = makeContext(SpringContext, {} as SpringContext)
+
+// Allow `useContext(SpringContext)` in TypeScript.
+SpringContext.Provider = ctx.Provider
+SpringContext.Consumer = ctx.Consumer
+
+/** Make the `target` compatible with `useContext` */
+function makeContext(target: any, init: T): React.Context {
+ Object.assign(target, React.createContext(init))
+ target.Provider._context = target
+ target.Consumer._context = target
+ return target
+}
diff --git a/packages/core/src/SpringPhase.ts b/packages/core/src/SpringPhase.ts
new file mode 100644
index 0000000000..6c0a2c0152
--- /dev/null
+++ b/packages/core/src/SpringPhase.ts
@@ -0,0 +1,24 @@
+/** The property symbol of the current animation phase. */
+const $P = Symbol.for('SpringPhase')
+
+const HAS_ANIMATED = 1
+const IS_ANIMATING = 2
+const IS_PAUSED = 4
+
+/** Returns true if the `target` has ever animated. */
+export const hasAnimated = (target: any) => (target[$P] & HAS_ANIMATED) > 0
+
+/** Returns true if the `target` is animating (even if paused). */
+export const isAnimating = (target: any) => (target[$P] & IS_ANIMATING) > 0
+
+/** Returns true if the `target` is paused (even if idle). */
+export const isPaused = (target: any) => (target[$P] & IS_PAUSED) > 0
+
+/** Set the active bit of the `target` phase. */
+export const setActiveBit = (target: any, active: boolean) =>
+ active
+ ? (target[$P] |= IS_ANIMATING | HAS_ANIMATED)
+ : (target[$P] &= ~IS_ANIMATING)
+
+export const setPausedBit = (target: any, paused: boolean) =>
+ paused ? (target[$P] |= IS_PAUSED) : (target[$P] &= ~IS_PAUSED)
diff --git a/packages/core/src/SpringRef.ts b/packages/core/src/SpringRef.ts
new file mode 100644
index 0000000000..e4148a0473
--- /dev/null
+++ b/packages/core/src/SpringRef.ts
@@ -0,0 +1,115 @@
+import { each, is } from '@react-spring/shared'
+import { Lookup, Falsy, OneOrMore } from '@react-spring/types'
+import { AsyncResult, ControllerUpdate } from './types'
+import { Controller } from './Controller'
+
+interface ControllerUpdateFn {
+ (i: number, ctrl: Controller): ControllerUpdate | Falsy
+}
+
+export class SpringRef {
+ readonly current: Controller[] = []
+
+ /** Update the state of each controller without animating. */
+ set(values: Partial) {
+ each(this.current, ctrl => ctrl.set(values))
+ }
+
+ /** Start the queued animations of each controller. */
+ start(): AsyncResult>[]
+ /** Update every controller with the same props. */
+ start(props: ControllerUpdate): AsyncResult>[]
+ /** Update controllers based on their state. */
+ start(props: ControllerUpdateFn): AsyncResult>[]
+ /** Start animating each controller. */
+ start(
+ props?: ControllerUpdate | ControllerUpdateFn
+ ): AsyncResult>[]
+ /** @internal */
+ start(props?: object | ControllerUpdateFn) {
+ const results: AsyncResult[] = []
+
+ each(this.current, (ctrl, i) => {
+ if (is.und(props)) {
+ results.push(ctrl.start())
+ } else {
+ const update = this._getProps(props, ctrl, i)
+ if (update) {
+ results.push(ctrl.start(update))
+ }
+ }
+ })
+
+ return results
+ }
+
+ /** Add the same props to each controller's update queue. */
+ update(props: ControllerUpdate): this
+ /** Generate separate props for each controller's update queue. */
+ update(props: ControllerUpdateFn): this
+ /** Add props to each controller's update queue. */
+ update(props: ControllerUpdate | ControllerUpdateFn): this
+ /** @internal */
+ update(props: object | ControllerUpdateFn) {
+ each(this.current, (ctrl, i) => ctrl.update(this._getProps(props, ctrl, i)))
+ return this
+ }
+
+ /** Add a controller to this ref */
+ add(ctrl: Controller) {
+ if (!this.current.includes(ctrl)) {
+ this.current.push(ctrl)
+ }
+ }
+
+ /** Remove a controller from this ref */
+ delete(ctrl: Controller) {
+ const i = this.current.indexOf(ctrl)
+ if (~i) this.current.splice(i, 1)
+ }
+
+ /** Overridden by `useTrail` to manipulate props */
+ protected _getProps(
+ arg: ControllerUpdate | ControllerUpdateFn,
+ ctrl: Controller,
+ index: number
+ ): ControllerUpdate | Falsy {
+ return is.fun(arg) ? arg(index, ctrl) : arg
+ }
+}
+
+export interface SpringRef {
+ /** Stop all animations. */
+ stop(): this
+ /** Stop animations for the given keys. */
+ stop(keys: OneOrMore): this
+ /** Cancel all animations. */
+ stop(cancel: boolean): this
+ /** Cancel animations for the given keys. */
+ stop(cancel: boolean, keys: OneOrMore): this
+ /** Stop some or all animations. */
+ stop(keys?: OneOrMore): this
+ /** Cancel some or all animations. */
+ stop(cancel: boolean, keys?: OneOrMore): this
+
+ /** Pause all animations. */
+ pause(): this
+ /** Pause animations for the given keys. */
+ pause(keys: OneOrMore): this
+ /** Pause some or all animations. */
+ pause(keys?: OneOrMore): this
+
+ /** Resume all animations. */
+ resume(): this
+ /** Resume animations for the given keys. */
+ resume(keys: OneOrMore): this
+ /** Resume some or all animations. */
+ resume(keys?: OneOrMore): this
+}
+
+each(['stop', 'pause', 'resume'] as const, key => {
+ SpringRef.prototype[key] = function (this: SpringRef) {
+ each(this.current, ctrl => ctrl[key](...arguments))
+ return this
+ } as any
+})
diff --git a/packages/core/src/SpringValue.test.ts b/packages/core/src/SpringValue.test.ts
new file mode 100644
index 0000000000..0aceed9837
--- /dev/null
+++ b/packages/core/src/SpringValue.test.ts
@@ -0,0 +1,973 @@
+import { SpringValue } from './SpringValue'
+import { FrameValue } from './FrameValue'
+import { flushMicroTasks } from 'flush-microtasks'
+import {
+ addFluidObserver,
+ FluidObserver,
+ getFluidObservers,
+ Globals,
+ removeFluidObserver,
+} from '@react-spring/shared'
+
+const frameLength = 1000 / 60
+
+describe('SpringValue', () => {
+ it('can animate a number', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, {
+ config: { duration: 10 * frameLength },
+ })
+ await advanceUntilIdle()
+ const frames = getFrames(spring)
+ expect(frames).toMatchSnapshot()
+ })
+
+ it('can animate a string', async () => {
+ const spring = new SpringValue()
+ const promise = spring.start({
+ to: '10px 20px',
+ from: '0px 0px',
+ config: { duration: 10 * frameLength },
+ })
+ await advanceUntilIdle()
+ const frames = getFrames(spring)
+ expect(frames).toMatchSnapshot()
+ const { finished } = await promise
+ expect(finished).toBeTruthy()
+ })
+
+ // FIXME: This test fails.
+ xit('animates a number the same as a numeric string', async () => {
+ const spring1 = new SpringValue(0)
+ spring1.start(10)
+
+ await advanceUntilIdle()
+ const frames = getFrames(spring1).map(n => n + 'px')
+
+ const spring2 = new SpringValue('0px')
+ spring2.start('10px')
+
+ await advanceUntilIdle()
+ expect(frames).toEqual(getFrames(spring2))
+ })
+
+ it('can animate an array of numbers', async () => {
+ const onChange = jest.fn()
+ const spring = new SpringValue()
+ spring.start({
+ to: [10, 20],
+ from: [0, 0],
+ config: { duration: 10 * frameLength },
+ onChange,
+ })
+ await advanceUntilIdle()
+ expect(onChange.mock.calls.slice(-1)[0]).toEqual([
+ spring.animation.to,
+ spring,
+ ])
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+
+ it('can have an animated string as its target', async () => {
+ const target = new SpringValue('yellow')
+ const spring = new SpringValue({
+ to: target,
+ config: { duration: 10 * frameLength },
+ })
+
+ // The target is not attached until the spring is observed.
+ addFluidObserver(spring, () => {})
+
+ mockRaf.step()
+ target.set('red')
+
+ await advanceUntilIdle()
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+
+ describeProps()
+ describeEvents()
+ describeMethods()
+ describeGlobals()
+
+ describeTarget('another SpringValue', from => {
+ const node = new SpringValue(from)
+ return {
+ node,
+ set: node.set.bind(node),
+ start: node.start.bind(node),
+ reset: node.reset.bind(node),
+ }
+ })
+
+ describeTarget('an Interpolation', from => {
+ const parent = new SpringValue(from - 1)
+ const node = parent.to(n => n + 1)
+ return {
+ node,
+ set: n => parent.set(n - 1),
+ start: n => parent.start(n - 1),
+ reset: parent.reset.bind(parent),
+ }
+ })
+
+ // No-op updates don't change the goal value.
+ describe('no-op updates', () => {
+ it('resolves when the animation is finished', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1)
+
+ // Create a no-op update.
+ const resolve = jest.fn()
+ spring.start(1).then(resolve)
+
+ await flushMicroTasks()
+ expect(resolve).not.toBeCalled()
+
+ await advanceUntilIdle()
+ expect(resolve).toBeCalled()
+ })
+ })
+})
+
+function describeProps() {
+ describeToProp()
+ describeFromProp()
+ describeResetProp()
+ describeDefaultProp()
+ describeReverseProp()
+ describeImmediateProp()
+ describeConfigProp()
+ describeLoopProp()
+ describeDelayProp()
+}
+
+function describeToProp() {
+ describe('when "to" prop is changed', () => {
+ it.todo('resolves the "start" promise with (finished: false)')
+ it.todo('avoids calling the "onStart" prop')
+ it.todo('avoids calling the "onRest" prop')
+ })
+
+ describe('when "to" prop equals current value', () => {
+ it('cancels any pending animation', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1)
+
+ // Prevent the animation to 1 (which hasn't started yet)
+ spring.start(0)
+
+ await advanceUntilIdle()
+ expect(getFrames(spring)).toEqual([])
+ })
+
+ it('avoids interrupting an active animation', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1)
+ await advance()
+
+ const goal = spring.get()
+ spring.start(goal)
+ expect(spring.idle).toBeFalsy()
+
+ await advanceUntilIdle()
+ expect(spring.get()).toBe(goal)
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+ })
+
+ describe('when "to" prop is a function', () => {
+ describe('and "from" prop is defined', () => {
+ it('stops the active animation before "to" is called', () => {
+ const spring = new SpringValue({ from: 0, to: 1 })
+ mockRaf.step()
+
+ expect.assertions(1)
+ spring.start({
+ from: 2,
+ to: () => {
+ expect(spring.get()).toBe(2)
+ },
+ })
+ })
+ })
+ })
+}
+
+function describeFromProp() {
+ describe('when "from" prop is defined', () => {
+ it.todo('controls the start value')
+ })
+}
+
+function describeResetProp() {
+ describe('when "reset" prop is true', () => {
+ it('calls "onRest" before jumping back to its "from" value', async () => {
+ const onRest = jest.fn((result: any) => {
+ expect(result.value).not.toBe(0)
+ })
+
+ const spring = new SpringValue({ from: 0, to: 1, onRest })
+ mockRaf.step()
+
+ spring.start({ reset: true })
+
+ expect(onRest).toHaveBeenCalled()
+ expect(spring.get()).toBe(0)
+ })
+
+ it.todo('resolves the "start" promise with (finished: false)')
+ it.todo('calls the "onRest" prop with (finished: false)')
+ })
+}
+
+function describeDefaultProp() {
+ // The hook API always uses { default: true } for render-driven updates.
+ // Some props can have default values (eg: onRest, config, etc), and
+ // other props may behave differently when { default: true } is used.
+ describe('when "default" prop is true', () => {
+ describe('and "from" prop is changed', () => {
+ describe('before the first animation', () => {
+ it('updates the current value', () => {
+ const props = { default: true, from: 1, to: 1 }
+ const spring = new SpringValue(props)
+
+ expect(spring.get()).toBe(1)
+ expect(spring.idle).toBeTruthy()
+
+ props.from = 0
+ spring.start(props)
+
+ expect(spring.get()).not.toBe(1)
+ expect(spring.idle).toBeFalsy()
+ })
+ })
+
+ describe('after the first animation', () => {
+ it('does not start animating', async () => {
+ const props = { default: true, from: 0, to: 2 }
+ const spring = new SpringValue(props)
+ await advanceUntilIdle()
+
+ props.from = 1
+ spring.start(props)
+
+ expect(spring.get()).toBe(2)
+ expect(spring.idle).toBeTruthy()
+ expect(spring.animation.from).toBe(1)
+ })
+
+ describe('and "reset" prop is true', () => {
+ it('starts at the "from" prop', async () => {
+ const props: any = { default: true, from: 0, to: 2 }
+ const spring = new SpringValue(props)
+ await advanceUntilIdle()
+
+ props.from = 1
+ props.reset = true
+ spring.start(props)
+
+ expect(spring.animation.from).toBe(1)
+ expect(spring.idle).toBeFalsy()
+ })
+ })
+ })
+ })
+ })
+
+ describe('when "default" prop is false', () => {
+ describe('and "from" prop is defined', () => {
+ it('updates the current value', () => {
+ const spring = new SpringValue(0)
+ spring.start({ from: 1 })
+ expect(spring.get()).toBe(1)
+ })
+ it('updates the "from" value', () => {
+ const spring = new SpringValue(0)
+ spring.start({ from: 1 })
+ expect(spring.animation.from).toBe(1)
+ })
+
+ describe('and "to" prop is undefined', () => {
+ it('updates the "to" value', () => {
+ const spring = new SpringValue(0)
+ spring.start({ from: 1 })
+ expect(spring.animation.to).toBe(1)
+ })
+ it('stops the active animation', async () => {
+ const spring = new SpringValue(0)
+
+ // This animation will be stopped.
+ const promise = spring.start({ from: 0, to: 1 })
+
+ mockRaf.step()
+ const value = spring.get()
+
+ spring.start({ from: 0 })
+ expect(spring.idle).toBeTruthy()
+ expect(spring.animation.to).toBe(0)
+
+ expect(await promise).toMatchObject({
+ value,
+ finished: false,
+ })
+ })
+ })
+ })
+ })
+}
+
+function describeReverseProp() {
+ describe('when "reverse" prop is true', () => {
+ it('swaps the "to" and "from" props', async () => {
+ const spring = new SpringValue()
+ spring.start({ from: 0, to: 1, reverse: true })
+
+ await advanceUntilIdle()
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+
+ it('works when "to" and "from" were set by an earlier update', async () => {
+ // TODO: remove the need for ""
+ const spring = new SpringValue({ from: 0, to: 1 })
+ await advanceUntilValue(spring, 0.5)
+
+ spring.start({ reverse: true })
+ expect(spring.animation).toMatchObject({
+ from: 1,
+ to: 0,
+ })
+
+ await advanceUntilIdle()
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+
+ it('works when "from" was set by an earlier update', async () => {
+ const spring = new SpringValue(0)
+ expect(spring.animation.from).toBe(0)
+ spring.start({ to: 1, reverse: true })
+
+ await advanceUntilIdle()
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+
+ it('preserves the reversal for future updates', async () => {
+ const spring = new SpringValue(0)
+ spring.start({ to: 1, reverse: true })
+ expect(spring.animation).toMatchObject({
+ to: 0,
+ from: 1,
+ })
+
+ await advanceUntilIdle()
+
+ spring.start({ to: 2 })
+ expect(spring.animation).toMatchObject({
+ to: 2,
+ from: 1,
+ })
+ })
+ })
+}
+
+function describeImmediateProp() {
+ describe('when "immediate" prop is true', () => {
+ it.todo('still resolves the "start" promise')
+ it.todo('never calls the "onStart" prop')
+ it.todo('never calls the "onRest" prop')
+
+ it('stops animating', async () => {
+ const spring = new SpringValue(0)
+ spring.start(2)
+ await advanceUntilValue(spring, 1)
+
+ // Use "immediate" to emulate the "stop" method. (see #884)
+ const value = spring.get()
+ spring.start(value, { immediate: true })
+
+ // The "immediate" prop waits until the next frame before going idle.
+ mockRaf.step()
+
+ expect(spring.idle).toBeTruthy()
+ expect(spring.get()).toBe(value)
+ })
+ })
+
+ describe('when "immediate: true" is followed by "immediate: false" in same frame', () => {
+ it('applies the immediate goal synchronously', () => {
+ const spring = new SpringValue(0)
+
+ // The immediate update is applied in the next frame.
+ spring.start({ to: 1, immediate: true })
+ expect(spring.get()).toBe(0)
+
+ // But when an animated update is merged before the next frame,
+ // the immediate update is applied synchronously.
+ spring.start({ to: 2 })
+ expect(spring.get()).toBe(1)
+ expect(spring.animation).toMatchObject({
+ fromValues: [1],
+ toValues: [2],
+ })
+ })
+
+ it('does nothing if the 2nd update has "reset: true"', () => {
+ const spring = new SpringValue(0)
+
+ // The immediate update is applied in the next frame.
+ spring.start({ to: 1, immediate: true })
+ expect(spring.get()).toBe(0)
+
+ // But when an animated update is merged before the next frame,
+ // the immediate update is applied synchronously.
+ spring.start({ to: 2, reset: true })
+ expect(spring.get()).toBe(0)
+ expect(spring.animation).toMatchObject({
+ fromValues: [0],
+ toValues: [2],
+ })
+ })
+ })
+}
+
+function describeConfigProp() {
+ describe('the "config" prop', () => {
+ it('resets the velocity when "to" changes', () => {
+ const spring = new SpringValue(0)
+ spring.start({ to: 100, config: { velocity: 10 } })
+
+ const { config } = spring.animation
+ expect(config.velocity).toBe(10)
+
+ // Preserve velocity if "to" did not change.
+ spring.start({ config: { tension: 200 } })
+ expect(config.velocity).toBe(10)
+
+ spring.start({ to: 200 })
+ expect(config.velocity).toBe(0)
+ })
+ describe('when "damping" is 1.0', () => {
+ it('should prevent bouncing', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, {
+ config: { frequency: 1.5, damping: 1 },
+ })
+ await advanceUntilIdle()
+ expect(countBounces(spring)).toBe(0)
+ })
+ })
+ describe('when "damping" is less than 1.0', () => {
+ // FIXME: This test fails.
+ xit('should bounce', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, {
+ config: { frequency: 1.5, damping: 1 },
+ })
+ await advanceUntilIdle()
+ expect(countBounces(spring)).toBeGreaterThan(0)
+ })
+ })
+ })
+}
+
+function describeLoopProp() {
+ describe('the "loop" prop', () => {
+ it('resets the animation once finished', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, {
+ loop: true,
+ config: { duration: frameLength * 3 },
+ })
+
+ await advanceUntilValue(spring, 1)
+ const firstRun = getFrames(spring)
+ expect(firstRun).toMatchSnapshot()
+
+ // The loop resets the value before the next frame.
+ // FIXME: Reset on next frame instead?
+ expect(spring.get()).toBe(0)
+
+ await advanceUntilValue(spring, 1)
+ expect(getFrames(spring)).toEqual(firstRun)
+ })
+
+ it('can pass a custom delay', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, {
+ loop: { reset: true, delay: 1000 },
+ })
+
+ await advanceUntilValue(spring, 1)
+ expect(spring.get()).toBe(1)
+
+ mockRaf.step({ time: 1000 })
+ expect(spring.get()).toBeLessThan(1)
+
+ await advanceUntilValue(spring, 1)
+ expect(spring.get()).toBe(1)
+ })
+
+ it('supports deferred evaluation', async () => {
+ const spring = new SpringValue(0)
+
+ let loop: any = true
+ spring.start(1, { loop: () => loop })
+
+ await advanceUntilValue(spring, 1)
+ expect(spring.idle).toBeFalsy()
+ expect(spring.get()).toBeLessThan(1)
+
+ loop = { reset: true, delay: 1000 }
+ await advanceUntilValue(spring, 1)
+ expect(spring.idle).toBeTruthy()
+ expect(spring.get()).toBe(1)
+
+ mockRaf.step({ time: 1000 })
+ expect(spring.idle).toBeFalsy()
+ expect(spring.get()).toBeLessThan(1)
+
+ loop = false
+ await advanceUntilValue(spring, 1)
+ expect(spring.idle).toBeTruthy()
+ expect(spring.get()).toBe(1)
+ })
+
+ it('does not affect later updates', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, { loop: true })
+
+ await advanceUntilValue(spring, 0.5)
+ spring.start(2)
+
+ await advanceUntilValue(spring, 2)
+ expect(spring.idle).toBeTruthy()
+ })
+
+ it('can be combined with the "reset" prop', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1)
+
+ await advanceUntilIdle()
+ spring.start({ reset: true, loop: true })
+ expect(spring.get()).toBe(0)
+
+ await advanceUntilValue(spring, 1)
+ expect(spring.get()).toBe(0)
+ expect(spring.idle).toBeFalsy()
+ })
+
+ it('can be combined with the "reverse" prop', async () => {
+ const spring = new SpringValue(0)
+ spring.start(1, { config: { duration: frameLength * 3 } })
+
+ await advanceUntilIdle()
+ spring.start({
+ loop: { reverse: true },
+ })
+
+ await advanceUntilValue(spring, 0)
+ await advanceUntilValue(spring, 1)
+ expect(getFrames(spring)).toMatchSnapshot()
+ })
+ })
+}
+
+function describeDelayProp() {
+ describe('the "delay" prop', () => {
+ // "Temporal prevention" means a delayed update can be cancelled by an
+ // earlier update. This removes the need for explicit delay cancellation.
+ it('allows the update to be temporally prevented', async () => {
+ const spring = new SpringValue(0)
+ const anim = spring.animation
+
+ spring.start(1, { config: { duration: 1000 } })
+
+ // This update will be ignored, because the next "start" call updates
+ // the "to" prop before this update's delay is finished. This update
+ // would *not* be ignored be if its "to" prop was undefined.
+ spring.start(2, { delay: 500, immediate: true })
+
+ // This update won't be affected by the previous update.
+ spring.start(0, { delay: 100, config: { duration: 1000 } })
+
+ expect(anim.to).toBe(1)
+ await advanceByTime(100)
+ expect(anim.to).toBe(0)
+
+ await advanceByTime(400)
+ expect(anim.immediate).toBeFalsy()
+ expect(anim.to).toBe(0)
+ })
+ })
+}
+
+function describeEvents() {
+ describe('the "onStart" event', () => {
+ it('is called on the first frame', async () => {
+ const onStart = jest.fn()
+ const spring = new SpringValue(0, { onStart })
+
+ spring.start(1)
+ expect(onStart).toBeCalledTimes(0)
+
+ mockRaf.step()
+ expect(onStart).toBeCalledTimes(1)
+
+ await advanceUntilIdle()
+ expect(onStart).toBeCalledTimes(1)
+ })
+ it('is called by the "finish" method', () => {
+ const onStart = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onStart })
+ expect(onStart).toBeCalledTimes(0)
+
+ spring.finish()
+ expect(onStart).toBeCalledTimes(1)
+ })
+ })
+ describe('the "onChange" event', () => {
+ it('is called on every frame', async () => {
+ const onChange = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onChange })
+
+ await advanceUntilIdle()
+ const frames = getFrames(spring)
+ expect(onChange).toBeCalledTimes(frames.length)
+ })
+ it('receives the "to" value on the last frame', async () => {
+ const onChange = jest.fn()
+ const spring = new SpringValue('blue', { onChange })
+
+ spring.start('red')
+ await advanceUntilIdle()
+
+ const [lastValue] = onChange.mock.calls.slice(-1)[0]
+ expect(lastValue).toBe('red')
+ })
+ it('is called by the "set" method', () => {
+ const onChange = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onChange })
+
+ mockRaf.step()
+ expect(onChange).toBeCalledTimes(1)
+
+ // During an animation:
+ spring.set(1)
+ expect(onChange).toBeCalledTimes(2)
+ expect(spring.idle).toBeTruthy()
+
+ // While idle:
+ spring.set(0)
+ expect(onChange).toBeCalledTimes(3)
+
+ // No-op calls are ignored:
+ spring.set(0)
+ expect(onChange).toBeCalledTimes(3)
+ })
+ describe('when active handler', () => {
+ it('is never called by the "set" method', () => {
+ const spring = new SpringValue(0)
+
+ const onChange = jest.fn()
+ spring.start(1, { onChange })
+
+ // Before first frame
+ spring.set(2)
+ expect(onChange).not.toBeCalled()
+
+ spring.start(1, { onChange })
+ mockRaf.step()
+ onChange.mockReset()
+
+ // Before last frame
+ spring.set(0)
+ expect(onChange).not.toBeCalled()
+ })
+ })
+ })
+ describe('the "onPause" event', () => {
+ it('is called by the "pause" method', () => {
+ const onPause = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onPause })
+
+ mockRaf.step()
+ spring.pause()
+ spring.pause() // noop
+
+ expect(onPause).toBeCalledTimes(1)
+
+ spring.resume()
+ spring.pause()
+
+ expect(onPause).toBeCalledTimes(2)
+ })
+ })
+ describe('the "onResume" event', () => {
+ it('is called by the "resume" method', () => {
+ const onResume = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onResume })
+
+ mockRaf.step()
+ spring.resume() // noop
+
+ expect(onResume).toBeCalledTimes(0)
+
+ spring.pause()
+ spring.resume()
+
+ expect(onResume).toBeCalledTimes(1)
+ })
+ })
+ describe('the "onRest" event', () => {
+ it('is called on the last frame', async () => {
+ const onRest = jest.fn()
+ // @ts-ignore
+ const spring = new SpringValue({
+ from: 0,
+ to: 1,
+ onRest,
+ config: {
+ // The animation is 3 frames long.
+ duration: 3 * frameLength,
+ },
+ })
+
+ mockRaf.step()
+ mockRaf.step()
+ expect(onRest).not.toBeCalled()
+
+ mockRaf.step()
+ expect(onRest).toBeCalledTimes(1)
+ })
+ it('is called by the "stop" method', () => {
+ const onRest = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onRest })
+
+ mockRaf.step()
+ spring.stop()
+
+ expect(onRest).toBeCalledTimes(1)
+ expect(onRest.mock.calls[0][0]).toMatchObject({
+ value: spring.get(),
+ finished: false,
+ })
+ })
+ it('is called by the "finish" method', () => {
+ const onRest = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onRest })
+
+ mockRaf.step()
+ spring.finish()
+
+ expect(onRest).toBeCalledTimes(1)
+ expect(onRest.mock.calls[0][0]).toMatchObject({
+ value: 1,
+ finished: true,
+ })
+ })
+ it('is called when the "cancel" prop is true', () => {
+ const onRest = jest.fn()
+ const spring = new SpringValue({ from: 0, to: 1, onRest })
+
+ mockRaf.step()
+ spring.start({ cancel: true })
+
+ expect(onRest).toBeCalledTimes(1)
+ expect(onRest.mock.calls[0][0]).toMatchObject({
+ value: spring.get(),
+ cancelled: true,
+ })
+ })
+ it('is called after an async animation', async () => {
+ const onRest = jest.fn()
+ const spring = new SpringValue(0)
+
+ await spring.start({
+ to: () => {},
+ onRest,
+ })
+
+ expect(onRest).toBeCalledTimes(1)
+ expect(onRest.mock.calls[0][0]).toMatchObject({
+ value: spring.get(),
+ finished: true,
+ })
+ })
+ })
+}
+
+function describeMethods() {
+ describe('"set" method', () => {
+ it('stops the active animation', async () => {
+ const spring = new SpringValue(0)
+ const promise = spring.start(1)
+
+ await advanceUntilValue(spring, 0.5)
+ const value = spring.get()
+ spring.set(2)
+
+ expect(spring.idle).toBeTruthy()
+ expect(await promise).toMatchObject({
+ finished: false,
+ value,
+ })
+ })
+ })
+}
+
+/** The minimum requirements for testing a dynamic target */
+type OpaqueTarget = {
+ node: FrameValue
+ set: (value: number) => any
+ start: (value: number) => Promise
+ reset: () => void
+}
+
+function describeTarget(name: string, create: (from: number) => OpaqueTarget) {
+ describe('when our target is ' + name, () => {
+ let target: OpaqueTarget
+ let spring: SpringValue
+ let observer: FluidObserver
+ beforeEach(() => {
+ target = create(1)
+ spring = new SpringValue(0)
+ // The target is not attached until the spring is observed.
+ addFluidObserver(spring, (observer = () => {}))
+ })
+
+ it('animates toward the current value', async () => {
+ spring.start({ to: target.node })
+ expect(spring.priority).toBeGreaterThan(target.node.priority)
+ expect(spring.animation).toMatchObject({
+ to: target.node,
+ toValues: null,
+ })
+
+ await advanceUntilIdle()
+ expect(spring.get()).toBe(target.node.get())
+ })
+
+ it.todo('preserves its "onRest" prop between animations')
+
+ it('can change its target while animating', async () => {
+ spring.start({ to: target.node })
+ await advanceUntilValue(spring, target.node.get() / 2)
+
+ spring.start(0)
+ expect(spring.priority).toBe(0)
+ expect(spring.animation).toMatchObject({
+ to: 0,
+ toValues: [0],
+ })
+
+ await advanceUntilIdle()
+ expect(spring.get()).toBe(0)
+ })
+
+ describe('when target is done animating', () => {
+ it('keeps animating until the target is reached', async () => {
+ spring.start({ to: target.node })
+ target.start(1.1)
+
+ await advanceUntil(() => target.node.idle)
+ expect(spring.idle).toBeFalsy()
+
+ await advanceUntilIdle()
+ expect(spring.idle).toBeTruthy()
+ expect(spring.get()).toBe(target.node.get())
+ })
+ })
+
+ describe('when target animates after we go idle', () => {
+ it('starts animating', async () => {
+ spring.start({ to: target.node })
+ await advanceUntil(() => spring.idle)
+ // Clear the frame cache.
+ getFrames(spring)
+
+ target.start(2)
+ await advanceUntilIdle()
+
+ expect(getFrames(spring).length).toBeGreaterThan(1)
+ expect(spring.get()).toBe(target.node.get())
+ })
+ })
+
+ describe('when target has its value set (not animated)', () => {
+ it('animates toward the new value', async () => {
+ spring.start({ to: target.node })
+ await advanceUntilIdle()
+
+ target.set(2)
+ await advanceUntilIdle()
+
+ expect(getFrames(spring).length).toBeGreaterThan(1)
+ expect(spring.get()).toBe(target.node.get())
+ })
+ })
+
+ describe('when target resets its animation', () => {
+ it('keeps animating', async () => {
+ spring.start({ to: target.node })
+ target.start(2)
+
+ await advanceUntilValue(target.node, 1.5)
+ expect(target.node.idle).toBeFalsy()
+
+ target.reset()
+ expect(target.node.get()).toBe(1)
+
+ await advanceUntilIdle()
+ const frames = getFrames(spring)
+
+ expect(frames.length).toBeGreaterThan(1)
+ expect(spring.get()).toBe(target.node.get())
+ })
+ })
+
+ // In the case of an Interpolation, staying attached will prevent
+ // garbage collection when an animation loop is active, which results
+ // in a memory leak since the Interpolation stays in the frameloop.
+ describe('when our last child is detached', () => {
+ it('detaches from the target', () => {
+ spring.start({ to: target.node })
+
+ // Expect the target node to be attached.
+ expect(hasFluidObserver(target.node, spring)).toBeTruthy()
+
+ // Remove the observer.
+ removeFluidObserver(spring, observer)
+
+ // Expect the target node to be detached.
+ expect(hasFluidObserver(target.node, spring)).toBeFalsy()
+ })
+ })
+ })
+}
+
+function describeGlobals() {
+ const defaults = { ...Globals }
+ const resetGlobals = () => Globals.assign(defaults)
+ describe('"skipAnimation" global', () => {
+ afterEach(resetGlobals)
+ it('still calls "onStart", "onChange", and "onRest" props', async () => {
+ const spring = new SpringValue(0)
+
+ const onStart = jest.fn()
+ const onChange = jest.fn()
+ const onRest = jest.fn()
+
+ Globals.assign({ skipAnimation: true })
+ await spring.start(1, { onStart, onChange, onRest })
+
+ expect(onStart).toBeCalledTimes(1)
+ expect(onChange).toBeCalledTimes(1)
+ expect(onRest).toBeCalledTimes(1)
+ })
+ })
+}
+
+function hasFluidObserver(target: any, observer: FluidObserver) {
+ const observers = getFluidObservers(target)
+ return !!observers && observers.has(observer)
+}
diff --git a/packages/core/src/SpringValue.ts b/packages/core/src/SpringValue.ts
new file mode 100644
index 0000000000..8c57dcdf3c
--- /dev/null
+++ b/packages/core/src/SpringValue.ts
@@ -0,0 +1,1057 @@
+import {
+ is,
+ raf,
+ each,
+ isEqual,
+ toArray,
+ eachProp,
+ frameLoop,
+ flushCalls,
+ getFluidValue,
+ isAnimatedString,
+ FluidValue,
+ Globals as G,
+ callFluidObservers,
+ hasFluidValue,
+ addFluidObserver,
+ removeFluidObserver,
+ getFluidObservers,
+} from '@react-spring/shared'
+import {
+ Animated,
+ AnimatedValue,
+ AnimatedString,
+ getPayload,
+ getAnimated,
+ setAnimated,
+ getAnimatedType,
+} from '@react-spring/animated'
+import { Lookup } from '@react-spring/types'
+
+import { Animation } from './Animation'
+import { mergeConfig } from './AnimationConfig'
+import { scheduleProps } from './scheduleProps'
+import { runAsync, RunAsyncState, RunAsyncProps, stopAsync } from './runAsync'
+import {
+ callProp,
+ computeGoal,
+ matchProp,
+ inferTo,
+ getDefaultProps,
+ getDefaultProp,
+ isAsyncTo,
+ resolveProp,
+} from './helpers'
+import { FrameValue, isFrameValue } from './FrameValue'
+import {
+ isAnimating,
+ isPaused,
+ setPausedBit,
+ hasAnimated,
+ setActiveBit,
+} from './SpringPhase'
+import {
+ AnimationRange,
+ AnimationResolver,
+ EventKey,
+ PickEventFns,
+} from './types/internal'
+import { AsyncResult, SpringUpdate, VelocityProp, SpringProps } from './types'
+import {
+ getCombinedResult,
+ getCancelledResult,
+ getFinishedResult,
+ getNoopResult,
+} from './AnimationResult'
+
+declare const console: any
+
+interface DefaultSpringProps
+ extends Pick, 'pause' | 'cancel' | 'immediate' | 'config'>,
+ PickEventFns> {}
+
+/**
+ * Only numbers, strings, and arrays of numbers/strings are supported.
+ * Non-animatable strings are also supported.
+ */
+export class SpringValue extends FrameValue {
+ /** The property name used when `to` or `from` is an object. Useful when debugging too. */
+ key?: string
+
+ /** The animation state */
+ animation = new Animation()
+
+ /** The queue of pending props */
+ queue?: SpringUpdate[]
+
+ /** Some props have customizable default values */
+ defaultProps: DefaultSpringProps = {}
+
+ /** The state for `runAsync` calls */
+ protected _state: RunAsyncState> = {
+ paused: false,
+ pauseQueue: new Set(),
+ resumeQueue: new Set(),
+ timeouts: new Set(),
+ }
+
+ /** The promise resolvers of pending `start` calls */
+ protected _pendingCalls = new Set>()
+
+ /** The counter for tracking `scheduleProps` calls */
+ protected _lastCallId = 0
+
+ /** The last `scheduleProps` call that changed the `to` prop */
+ protected _lastToId = 0
+
+ constructor(from: Exclude, props?: SpringUpdate)
+ constructor(props?: SpringUpdate)
+ constructor(arg1?: any, arg2?: any) {
+ super()
+ if (!is.und(arg1) || !is.und(arg2)) {
+ const props = is.obj(arg1) ? { ...arg1 } : { ...arg2, from: arg1 }
+ if (is.und(props.default)) {
+ props.default = true
+ }
+ this.start(props)
+ }
+ }
+
+ /** Equals true when not advancing on each frame. */
+ get idle() {
+ return !(isAnimating(this) || this._state.asyncTo) || isPaused(this)
+ }
+
+ get goal() {
+ return getFluidValue(this.animation.to) as T
+ }
+
+ get velocity(): VelocityProp {
+ const node = getAnimated(this)!
+ return (node instanceof AnimatedValue
+ ? node.lastVelocity || 0
+ : node.getPayload().map(node => node.lastVelocity || 0)) as any
+ }
+
+ /**
+ * When true, this value has been animated at least once.
+ */
+ get hasAnimated() {
+ return hasAnimated(this)
+ }
+
+ /**
+ * When true, this value has an unfinished animation,
+ * which is either active or paused.
+ */
+ get isAnimating() {
+ return isAnimating(this)
+ }
+
+ /**
+ * When true, all current and future animations are paused.
+ */
+ get isPaused() {
+ return isPaused(this)
+ }
+
+ /** Advance the current animation by a number of milliseconds */
+ advance(dt: number) {
+ let idle = true
+ let changed = false
+
+ const anim = this.animation
+ let { config, toValues } = anim
+
+ const payload = getPayload(anim.to)
+ if (!payload && hasFluidValue(anim.to)) {
+ toValues = toArray(getFluidValue(anim.to)) as any
+ }
+
+ anim.values.forEach((node, i) => {
+ if (node.done) return
+
+ const to =
+ // Animated strings always go from 0 to 1.
+ node.constructor == AnimatedString
+ ? 1
+ : payload
+ ? payload[i].lastPosition
+ : toValues![i]
+
+ let finished = anim.immediate
+ let position = to
+
+ if (!finished) {
+ position = node.lastPosition
+
+ // Loose springs never move.
+ if (config.tension <= 0) {
+ node.done = true
+ return
+ }
+
+ const elapsed = (node.elapsedTime += dt)
+ const from = anim.fromValues[i]
+
+ const v0 =
+ node.v0 != null
+ ? node.v0
+ : (node.v0 = is.arr(config.velocity)
+ ? config.velocity[i]
+ : config.velocity)
+
+ let velocity: number
+
+ // Duration easing
+ if (!is.und(config.duration)) {
+ let p = 1
+ if (config.duration > 0) {
+ p = (config.progress || 0) + elapsed / config.duration
+ p = p > 1 ? 1 : p < 0 ? 0 : p
+ }
+
+ position = from + config.easing(p) * (to - from)
+ velocity = (position - node.lastPosition) / dt
+
+ finished = p == 1
+ }
+
+ // Decay easing
+ else if (config.decay) {
+ const decay = config.decay === true ? 0.998 : config.decay
+ const e = Math.exp(-(1 - decay) * elapsed)
+
+ position = from + (v0 / (1 - decay)) * (1 - e)
+ finished = Math.abs(node.lastPosition - position) < 0.1
+
+ // derivative of position
+ velocity = v0 * e
+ }
+
+ // Spring easing
+ else {
+ velocity = node.lastVelocity == null ? v0 : node.lastVelocity
+
+ /** The smallest distance from a value before being treated like said value. */
+ const precision =
+ config.precision ||
+ (from == to ? 0.005 : Math.min(1, Math.abs(to - from) * 0.001))
+
+ /** The velocity at which movement is essentially none */
+ const restVelocity = config.restVelocity || precision / 10
+
+ // Bouncing is opt-in (not to be confused with overshooting)
+ const bounceFactor = config.clamp ? 0 : config.bounce!
+ const canBounce = !is.und(bounceFactor)
+
+ /** When `true`, the value is increasing over time */
+ const isGrowing = from == to ? node.v0 > 0 : from < to
+
+ /** When `true`, the velocity is considered moving */
+ let isMoving!: boolean
+
+ /** When `true`, the velocity is being deflected or clamped */
+ let isBouncing = false
+
+ const step = 1 // 1ms
+ const numSteps = Math.ceil(dt / step)
+ for (let n = 0; n < numSteps; ++n) {
+ isMoving = Math.abs(velocity) > restVelocity
+
+ if (!isMoving) {
+ finished = Math.abs(to - position) <= precision
+ if (finished) {
+ break
+ }
+ }
+
+ if (canBounce) {
+ isBouncing = position == to || position > to == isGrowing
+
+ // Invert the velocity with a magnitude, or clamp it.
+ if (isBouncing) {
+ velocity = -velocity * bounceFactor
+ position = to
+ }
+ }
+
+ const springForce = -config.tension * 0.000001 * (position - to)
+ const dampingForce = -config.friction * 0.001 * velocity
+ const acceleration = (springForce + dampingForce) / config.mass // pt/ms^2
+
+ velocity = velocity + acceleration * step // pt/ms
+ position = position + velocity * step
+ }
+ }
+
+ node.lastVelocity = velocity
+
+ if (Number.isNaN(position)) {
+ console.warn(`Got NaN while animating:`, this)
+ finished = true
+ }
+ }
+
+ // Parent springs must finish before their children can.
+ if (payload && !payload[i].done) {
+ finished = false
+ }
+
+ if (finished) {
+ node.done = true
+ } else {
+ idle = false
+ }
+
+ if (node.setValue(position, config.round)) {
+ changed = true
+ }
+ })
+
+ const node = getAnimated(this)!
+ if (idle) {
+ const value = getFluidValue(anim.to)
+ if (node.setValue(value) || changed) {
+ this._onChange(value)
+ }
+ this._stop()
+ } else if (changed) {
+ this._onChange(node.getValue())
+ }
+ }
+
+ /** Set the current value, while stopping the current animation */
+ set(value: T | FluidValue) {
+ raf.batchedUpdates(() => {
+ this._stop()
+
+ // These override the current value and goal value that may have
+ // been updated by `onRest` handlers in the `_stop` call above.
+ this._focus(value)
+ this._set(value)
+ })
+ return this
+ }
+
+ /**
+ * Freeze the active animation in time, as well as any updates merged
+ * before `resume` is called.
+ */
+ pause() {
+ this._update({ pause: true })
+ }
+
+ /** Resume the animation if paused. */
+ resume() {
+ this._update({ pause: false })
+ }
+
+ /** Skip to the end of the current animation. */
+ finish() {
+ if (isAnimating(this)) {
+ const { to, config } = this.animation
+ raf.batchedUpdates(() => {
+ // Ensure the "onStart" and "onRest" props are called.
+ this._onStart()
+
+ // Jump to the goal value, except for decay animations
+ // which have an undefined goal value.
+ if (!config.decay) {
+ this._set(to, false)
+ }
+
+ this._stop()
+ })
+ }
+ return this
+ }
+
+ /** Push props into the pending queue. */
+ update(props: SpringUpdate) {
+ const queue = this.queue || (this.queue = [])
+ queue.push(props)
+ return this
+ }
+
+ /**
+ * Update this value's animation using the queue of pending props,
+ * and unpause the current animation (if one is frozen).
+ *
+ * When arguments are passed, a new animation is created, and the
+ * queued animations are left alone.
+ */
+ start(): AsyncResult
+
+ start(props: SpringUpdate): AsyncResult
+
+ start(to: T, props?: SpringProps): AsyncResult
+
+ start(to?: T | SpringUpdate, arg2?: SpringProps) {
+ let queue: SpringUpdate[]
+ if (!is.und(to)) {
+ queue = [is.obj(to) ? to : { ...arg2, to }]
+ } else {
+ queue = this.queue || []
+ this.queue = []
+ }
+
+ return Promise.all(queue.map(props => this._update(props))).then(results =>
+ getCombinedResult(this, results)
+ )
+ }
+
+ /**
+ * Stop the current animation, and cancel any delayed updates.
+ *
+ * Pass `true` to call `onRest` with `cancelled: true`.
+ */
+ stop(cancel?: boolean) {
+ const { to } = this.animation
+
+ // The current value becomes the goal value.
+ this._focus(this.get())
+
+ stopAsync(this._state, cancel && this._lastCallId)
+ raf.batchedUpdates(() => this._stop(to, cancel))
+
+ return this
+ }
+
+ /** Restart the animation. */
+ reset() {
+ this._update({ reset: true })
+ }
+
+ /** @internal */
+ eventObserved(event: FrameValue.Event) {
+ if (event.type == 'change') {
+ this._start()
+ } else if (event.type == 'priority') {
+ this.priority = event.priority + 1
+ }
+ }
+
+ /**
+ * Parse the `to` and `from` range from the given `props` object.
+ *
+ * This also ensures the initial value is available to animated components
+ * during the render phase.
+ */
+ protected _prepareNode(props: {
+ to?: any
+ from?: any
+ reverse?: boolean
+ default?: any
+ }) {
+ const key = this.key || ''
+
+ let { to, from } = props
+
+ to = is.obj(to) ? to[key] : to
+ if (to == null || isAsyncTo(to)) {
+ to = undefined
+ }
+
+ from = is.obj(from) ? from[key] : from
+ if (from == null) {
+ from = undefined
+ }
+
+ // Create the range now to avoid "reverse" logic.
+ const range = { to, from }
+
+ // Before ever animating, this method ensures an `Animated` node
+ // exists and keeps its value in sync with the "from" prop.
+ if (!hasAnimated(this)) {
+ if (props.reverse) [to, from] = [from, to]
+
+ from = getFluidValue(from)
+ if (!is.und(from)) {
+ this._set(from)
+ }
+ // Use the "to" value if our node is undefined.
+ else if (!getAnimated(this)) {
+ this._set(to)
+ }
+ }
+
+ return range
+ }
+
+ /** Every update is processed by this method before merging. */
+ protected _update(
+ { ...props }: SpringProps,
+ isLoop?: boolean
+ ): AsyncResult> {
+ const { key, defaultProps } = this
+
+ // Update the default props immediately.
+ if (props.default)
+ Object.assign(
+ defaultProps,
+ getDefaultProps(props, (value, prop) =>
+ /^on/.test(prop) ? resolveProp(value, key) : value
+ )
+ )
+
+ mergeActiveFn(this, props, 'onProps')
+ sendEvent(this, 'onProps', props, this)
+
+ // Ensure the initial value can be accessed by animated components.
+ const range = this._prepareNode(props)
+
+ if (Object.isFrozen(this)) {
+ throw Error(
+ 'Cannot animate a `SpringValue` object that is frozen. ' +
+ 'Did you forget to pass your component to `animated(...)` before animating its props?'
+ )
+ }
+
+ const state = this._state
+ return scheduleProps(++this._lastCallId, {
+ key,
+ props,
+ defaultProps,
+ state,
+ actions: {
+ pause: () => {
+ if (!isPaused(this)) {
+ setPausedBit(this, true)
+ flushCalls(state.pauseQueue)
+ sendEvent(this, 'onPause', this)
+ }
+ },
+ resume: () => {
+ if (isPaused(this)) {
+ setPausedBit(this, false)
+ if (isAnimating(this)) {
+ this._resume()
+ }
+ flushCalls(state.resumeQueue)
+ sendEvent(this, 'onResume', this)
+ }
+ },
+ start: this._merge.bind(this, range),
+ },
+ }).then(result => {
+ if (props.loop && result.finished && !(isLoop && result.noop)) {
+ const nextProps = createLoopUpdate(props)
+ if (nextProps) {
+ return this._update(nextProps, true)
+ }
+ }
+ return result
+ })
+ }
+
+ /** Merge props into the current animation */
+ protected _merge(
+ range: AnimationRange,
+ props: RunAsyncProps>,
+ resolve: AnimationResolver>
+ ): void {
+ // The "cancel" prop cancels all pending delays and it forces the
+ // active animation to stop where it is.
+ if (props.cancel) {
+ this.stop(true)
+ return resolve(getCancelledResult(this))
+ }
+
+ /** The "to" prop is defined. */
+ const hasToProp = !is.und(range.to)
+
+ /** The "from" prop is defined. */
+ const hasFromProp = !is.und(range.from)
+
+ // Avoid merging other props if implicitly prevented, except
+ // when both the "to" and "from" props are undefined.
+ if (hasToProp || hasFromProp) {
+ if (props.callId > this._lastToId) {
+ this._lastToId = props.callId
+ } else {
+ return resolve(getCancelledResult(this))
+ }
+ }
+
+ const { key, defaultProps, animation: anim } = this
+ const { to: prevTo, from: prevFrom } = anim
+ let { to = prevTo, from = prevFrom } = range
+
+ // Focus the "from" value if changing without a "to" value.
+ // For default updates, do this only if no "to" value exists.
+ if (hasFromProp && !hasToProp && (!props.default || is.und(to))) {
+ to = from
+ }
+
+ // Flip the current range if "reverse" is true.
+ if (props.reverse) [to, from] = [from, to]
+
+ /** The "from" value is changing. */
+ const hasFromChanged = !isEqual(from, prevFrom)
+
+ if (hasFromChanged) {
+ anim.from = from
+ }
+
+ // Coerce "from" into a static value.
+ from = getFluidValue(from)
+
+ /** The "to" value is changing. */
+ const hasToChanged = !isEqual(to, prevTo)
+
+ if (hasToChanged) {
+ this._focus(to)
+ }
+
+ /** The "to" prop is async. */
+ const hasAsyncTo = isAsyncTo(props.to)
+
+ const { config } = anim
+ const { decay, velocity } = config
+
+ // Reset to default velocity when goal values are defined.
+ if (hasToProp || hasFromProp) {
+ config.velocity = 0
+ }
+
+ // The "runAsync" function treats the "config" prop as a default,
+ // so we must avoid merging it when the "to" prop is async.
+ if (props.config && !hasAsyncTo) {
+ mergeConfig(
+ config,
+ callProp(props.config, key!),
+ // Avoid calling the same "config" prop twice.
+ props.config !== defaultProps.config
+ ? callProp(defaultProps.config, key!)
+ : void 0
+ )
+ }
+
+ // This instance might not have its Animated node yet. For example,
+ // the constructor can be given props without a "to" or "from" value.
+ let node = getAnimated(this)
+ if (!node || is.und(to)) {
+ return resolve(getFinishedResult(this, true))
+ }
+
+ /** When true, start at the "from" value. */
+ const reset =
+ // When `reset` is undefined, the `from` prop implies `reset: true`,
+ // except for declarative updates. When `reset` is defined, there
+ // must exist a value to animate from.
+ is.und(props.reset)
+ ? hasFromProp && !props.default
+ : !is.und(from) && matchProp(props.reset, key)
+
+ // The current value, where the animation starts from.
+ const value = reset ? (from as T) : this.get()
+
+ // The animation ends at this value, unless "to" is fluid.
+ const goal = computeGoal(to)
+
+ // Only specific types can be animated to/from.
+ const isAnimatable = is.num(goal) || is.arr(goal) || isAnimatedString(goal)
+
+ // When true, the value changes instantly on the next frame.
+ const immediate =
+ !hasAsyncTo &&
+ (!isAnimatable ||
+ matchProp(defaultProps.immediate || props.immediate, key))
+
+ if (hasToChanged) {
+ const nodeType = getAnimatedType(to)
+ if (nodeType !== node.constructor) {
+ if (immediate) {
+ node = this._set(goal)!
+ } else
+ throw Error(
+ `Cannot animate between ${node.constructor.name} and ${nodeType.name}, as the "to" prop suggests`
+ )
+ }
+ }
+
+ // The type of Animated node for the goal value.
+ const goalType = node.constructor
+
+ // When the goal value is fluid, we don't know if its value
+ // will change before the next animation frame, so it always
+ // starts the animation to be safe.
+ let started = hasFluidValue(to)
+ let finished = false
+
+ if (!started) {
+ // When true, the current value has probably changed.
+ const hasValueChanged = reset || (!hasAnimated(this) && hasFromChanged)
+
+ // When the "to" value or current value are changed,
+ // start animating if not already finished.
+ if (hasToChanged || hasValueChanged) {
+ finished = isEqual(computeGoal(value), goal)
+ started = !finished
+ }
+
+ // Changing "decay" or "velocity" starts the animation.
+ if (
+ !isEqual(config.decay, decay) ||
+ !isEqual(config.velocity, velocity)
+ ) {
+ started = true
+ }
+ }
+
+ // Was the goal value set to the current value while animating?
+ if (finished && isAnimating(this)) {
+ // If the first frame has passed, allow the animation to
+ // overshoot instead of stopping abruptly.
+ if (anim.changed && !reset) {
+ started = true
+ }
+ // Stop the animation before its first frame.
+ else if (!started) {
+ this._stop(prevTo)
+ }
+ }
+
+ if (!hasAsyncTo) {
+ // Make sure our "toValues" are updated even if our previous
+ // "to" prop is a fluid value whose current value is also ours.
+ if (started || hasFluidValue(prevTo)) {
+ anim.values = node.getPayload()
+ anim.toValues = hasFluidValue(to)
+ ? null
+ : goalType == AnimatedString
+ ? [1]
+ : toArray(goal)
+ }
+
+ if (anim.immediate != immediate) {
+ anim.immediate = immediate
+
+ // Ensure the immediate goal is used as from value.
+ if (!immediate && !reset) {
+ this._set(prevTo)
+ }
+ }
+
+ if (started) {
+ const { onRest } = anim
+
+ // Set the active handlers when an animation starts.
+ each(ACTIVE_EVENTS, type => mergeActiveFn(this, props, type))
+
+ const result = getFinishedResult(this, checkFinished(this, prevTo))
+ flushCalls(this._pendingCalls, result)
+ this._pendingCalls.add(resolve)
+
+ if (anim.changed)
+ raf.batchedUpdates(() => {
+ // Ensure `onStart` can be called after a reset.
+ anim.changed = !reset
+
+ // Call the active `onRest` handler from the interrupted animation.
+ onRest?.(result)
+
+ // Notify the default `onRest` of the reset, but wait for the
+ // first frame to pass before sending an `onStart` event.
+ if (reset) {
+ callProp(defaultProps.onRest, result)
+ }
+ // Call the active `onStart` handler here since the first frame
+ // has already passed, which means this is a goal update and not
+ // an entirely new animation.
+ else {
+ anim.onStart?.(this)
+ }
+ })
+ }
+ }
+
+ if (reset) {
+ this._set(value)
+ }
+
+ if (hasAsyncTo) {
+ resolve(runAsync(props.to, props, this._state, this))
+ }
+
+ // Start an animation
+ else if (started) {
+ this._start()
+ }
+
+ // Postpone promise resolution until the animation is finished,
+ // so that no-op updates still resolve at the expected time.
+ else if (isAnimating(this) && !hasToChanged) {
+ this._pendingCalls.add(resolve)
+ }
+
+ // Resolve our promise immediately.
+ else {
+ resolve(getNoopResult(this, value))
+ }
+ }
+
+ /** Update the `animation.to` value, which might be a `FluidValue` */
+ protected _focus(value: T | FluidValue) {
+ const anim = this.animation
+ if (value !== anim.to) {
+ if (getFluidObservers(this)) {
+ this._detach()
+ }
+ anim.to = value
+ if (getFluidObservers(this)) {
+ this._attach()
+ }
+ }
+ }
+
+ protected _attach() {
+ let priority = 0
+
+ const { to } = this.animation
+ if (hasFluidValue(to)) {
+ addFluidObserver(to, this)
+ if (isFrameValue(to)) {
+ priority = to.priority + 1
+ }
+ }
+
+ this.priority = priority
+ }
+
+ protected _detach() {
+ const { to } = this.animation
+ if (hasFluidValue(to)) {
+ removeFluidObserver(to, this)
+ }
+ }
+
+ /**
+ * Update the current value from outside the frameloop,
+ * and return the `Animated` node.
+ */
+ protected _set(arg: T | FluidValue, idle = true): Animated | undefined {
+ const value = getFluidValue(arg)
+ if (!is.und(value)) {
+ const oldNode = getAnimated(this)
+ if (!oldNode || !isEqual(value, oldNode.getValue())) {
+ // Create a new node or update the existing node.
+ const nodeType = getAnimatedType(value)
+ if (!oldNode || oldNode.constructor != nodeType) {
+ setAnimated(this, nodeType.create(value))
+ } else {
+ oldNode.setValue(value)
+ }
+ // Never emit a "change" event for the initial value.
+ if (oldNode) {
+ raf.batchedUpdates(() => {
+ this._onChange(value, idle)
+ })
+ }
+ }
+ }
+ return getAnimated(this)
+ }
+
+ protected _onStart() {
+ const anim = this.animation
+ if (!anim.changed) {
+ anim.changed = true
+ sendEvent(this, 'onStart', this)
+ }
+ }
+
+ protected _onChange(value: T, idle?: boolean) {
+ if (!idle) {
+ this._onStart()
+ callProp(this.animation.onChange, value, this)
+ }
+ callProp(this.defaultProps.onChange, value, this)
+ super._onChange(value, idle)
+ }
+
+ // This method resets the animation state (even if already animating) to
+ // ensure the latest from/to range is used, and it also ensures this spring
+ // is added to the frameloop.
+ protected _start() {
+ const anim = this.animation
+
+ // Reset the state of each Animated node.
+ getAnimated(this)!.reset(getFluidValue(anim.to))
+
+ // Use the current values as the from values.
+ if (!anim.immediate) {
+ anim.fromValues = anim.values.map(node => node.lastPosition)
+ }
+
+ if (!isAnimating(this)) {
+ setActiveBit(this, true)
+ if (!isPaused(this)) {
+ this._resume()
+ }
+ }
+ }
+
+ protected _resume() {
+ // The "skipAnimation" global avoids the frameloop.
+ if (G.skipAnimation) {
+ this.finish()
+ } else {
+ frameLoop.start(this)
+ }
+ }
+
+ /**
+ * Exit the frameloop and notify `onRest` listeners.
+ *
+ * Always wrap `_stop` calls with `batchedUpdates`.
+ */
+ protected _stop(goal?: any, cancel?: boolean) {
+ if (isAnimating(this)) {
+ setActiveBit(this, false)
+
+ const anim = this.animation
+ each(anim.values, node => {
+ node.done = true
+ })
+
+ // These active handlers must be reset to undefined or else
+ // they could be called while idle. But keep them defined
+ // when the goal value is dynamic.
+ if (anim.toValues) {
+ anim.onChange = anim.onPause = anim.onResume = undefined
+ }
+
+ callFluidObservers(this, {
+ type: 'idle',
+ parent: this,
+ })
+
+ const result = cancel
+ ? getCancelledResult(this)
+ : getFinishedResult(this, checkFinished(this, goal ?? anim.to))
+
+ flushCalls(this._pendingCalls, result)
+ if (anim.changed) {
+ anim.changed = false
+ sendEvent(this, 'onRest', result)
+ }
+ }
+ }
+}
+
+/** Returns true when the current value and goal value are equal. */
+function checkFinished(target: SpringValue, to: T | FluidValue) {
+ const goal = computeGoal(to)
+ const value = computeGoal(target.get())
+ return isEqual(value, goal)
+}
+
+export function createLoopUpdate(
+ props: T & { loop?: any; to?: any; from?: any; reverse?: any },
+ loop = props.loop,
+ to = props.to
+): T | undefined {
+ let loopRet = callProp(loop)
+ if (loopRet) {
+ const overrides = loopRet !== true && inferTo(loopRet)
+ const reverse = (overrides || props).reverse
+ const reset = !overrides || overrides.reset
+ return createUpdate({
+ ...props,
+ loop,
+
+ // Avoid updating default props when looping.
+ default: false,
+
+ // Never loop the `pause` prop.
+ pause: undefined,
+
+ // For the "reverse" prop to loop as expected, the "to" prop
+ // must be undefined. The "reverse" prop is ignored when the
+ // "to" prop is an array or function.
+ to: !reverse || isAsyncTo(to) ? to : undefined,
+
+ // Ignore the "from" prop except on reset.
+ from: reset ? props.from : undefined,
+ reset,
+
+ // The "loop" prop can return a "useSpring" props object to
+ // override any of the original props.
+ ...overrides,
+ })
+ }
+}
+
+/**
+ * Return a new object based on the given `props`.
+ *
+ * - All non-reserved props are moved into the `to` prop object.
+ * - The `keys` prop is set to an array of affected keys,
+ * or `null` if all keys are affected.
+ */
+export function createUpdate(props: any) {
+ const { to, from } = (props = inferTo(props))
+
+ // Collect the keys affected by this update.
+ const keys = new Set()
+
+ if (is.obj(to)) findDefined(to, keys)
+ if (is.obj(from)) findDefined(from, keys)
+
+ // The "keys" prop helps in applying updates to affected keys only.
+ props.keys = keys.size ? Array.from(keys) : null
+
+ return props
+}
+
+/**
+ * A modified version of `createUpdate` meant for declarative APIs.
+ */
+export function declareUpdate(props: any) {
+ const update = createUpdate(props)
+ if (is.und(update.default)) {
+ update.default = getDefaultProps(update)
+ }
+ return update
+}
+
+/** Find keys with defined values */
+function findDefined(values: Lookup, keys: Set) {
+ eachProp(values, (value, key) => value != null && keys.add(key as any))
+}
+
+/** Event props with "active handler" support */
+const ACTIVE_EVENTS = [
+ 'onStart',
+ 'onRest',
+ 'onChange',
+ 'onPause',
+ 'onResume',
+] as const
+
+function mergeActiveFn(
+ target: SpringValue,
+ props: SpringProps,
+ type: P
+) {
+ target.animation[type] =
+ props[type] !== getDefaultProp(props, type)
+ ? resolveProp(props[type], target.key)
+ : undefined
+}
+
+type EventArgs = Parameters<
+ Extract[P], Function>
+>
+
+/** Call the active handler first, then the default handler. */
+function sendEvent(
+ target: SpringValue,
+ type: P,
+ ...args: EventArgs
+) {
+ target.animation[type]?.(...(args as [any, any]))
+ target.defaultProps[type]?.(...(args as [any, any]))
+}
diff --git a/packages/core/src/TransitionPhase.ts b/packages/core/src/TransitionPhase.ts
new file mode 100644
index 0000000000..1d0b91d2d1
--- /dev/null
+++ b/packages/core/src/TransitionPhase.ts
@@ -0,0 +1,18 @@
+// TODO: convert to "const enum" once Babel supports it
+export type TransitionPhase =
+ | typeof MOUNT
+ | typeof ENTER
+ | typeof UPDATE
+ | typeof LEAVE
+
+/** This transition is being mounted */
+export const MOUNT = 'mount'
+
+/** This transition is entering or has entered */
+export const ENTER = 'enter'
+
+/** This transition had its animations updated */
+export const UPDATE = 'update'
+
+/** This transition will expire after animating */
+export const LEAVE = 'leave'
diff --git a/packages/core/src/__snapshots__/Controller.test.ts.snap b/packages/core/src/__snapshots__/Controller.test.ts.snap
new file mode 100644
index 0000000000..d029ef6902
--- /dev/null
+++ b/packages/core/src/__snapshots__/Controller.test.ts.snap
@@ -0,0 +1,552 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Controller can animate a number 1`] = `
+Array [
+ Object {
+ "x": 2.263484330785799,
+ },
+ Object {
+ "x": 7.615528894993947,
+ },
+ Object {
+ "x": 14.72729545687759,
+ },
+ Object {
+ "x": 22.67933293781441,
+ },
+ Object {
+ "x": 30.85053191603684,
+ },
+ Object {
+ "x": 38.83524421610096,
+ },
+ Object {
+ "x": 46.38171205392548,
+ },
+ Object {
+ "x": 53.346574480369156,
+ },
+ Object {
+ "x": 59.66146523130329,
+ },
+ Object {
+ "x": 65.30867180317823,
+ },
+ Object {
+ "x": 70.30355736424129,
+ },
+ Object {
+ "x": 74.68200657416291,
+ },
+ Object {
+ "x": 78.49158337229619,
+ },
+ Object {
+ "x": 81.78541406742545,
+ },
+ Object {
+ "x": 84.61805634019797,
+ },
+ Object {
+ "x": 87.04280232770594,
+ },
+ Object {
+ "x": 89.11000586164683,
+ },
+ Object {
+ "x": 90.8661309883985,
+ },
+ Object {
+ "x": 92.35329941206051,
+ },
+ Object {
+ "x": 93.60917483504707,
+ },
+ Object {
+ "x": 94.66706719880813,
+ },
+ Object {
+ "x": 95.55617327559861,
+ },
+ Object {
+ "x": 96.30189477450246,
+ },
+ Object {
+ "x": 96.92619326731115,
+ },
+ Object {
+ "x": 97.4479544590839,
+ },
+ Object {
+ "x": 97.88334387327176,
+ },
+ Object {
+ "x": 98.2461428371077,
+ },
+ Object {
+ "x": 98.54805845242963,
+ },
+ Object {
+ "x": 98.7990045563195,
+ },
+ Object {
+ "x": 99.0073529166361,
+ },
+ Object {
+ "x": 99.18015536949562,
+ },
+ Object {
+ "x": 99.3233385117055,
+ },
+ Object {
+ "x": 99.4418730756026,
+ },
+ Object {
+ "x": 99.53992035746593,
+ },
+ Object {
+ "x": 99.62095813165256,
+ },
+ Object {
+ "x": 99.68788842438258,
+ },
+ Object {
+ "x": 99.74312938902625,
+ },
+ Object {
+ "x": 99.78869335076726,
+ },
+ Object {
+ "x": 99.8262528947063,
+ },
+ Object {
+ "x": 99.85719667273446,
+ },
+ Object {
+ "x": 99.8826764105673,
+ },
+ Object {
+ "x": 100,
+ },
+]
+`;
+
+exports[`Controller can animate an array of numbers 1`] = `
+Array [
+ Object {
+ "x": Array [
+ 1.0905393732314317,
+ 2.1810787464628634,
+ ],
+ },
+ Object {
+ "x": Array [
+ 1.3046211557997573,
+ 2.6092423115995147,
+ ],
+ },
+ Object {
+ "x": Array [
+ 1.589091818275103,
+ 3.178183636550206,
+ ],
+ },
+ Object {
+ "x": Array [
+ 1.9071733175125758,
+ 3.8143466350251516,
+ ],
+ },
+ Object {
+ "x": Array [
+ 2.2340212766414735,
+ 4.468042553282947,
+ ],
+ },
+ Object {
+ "x": Array [
+ 2.5534097686440376,
+ 5.106819537288075,
+ ],
+ },
+ Object {
+ "x": Array [
+ 2.8552684821570185,
+ 5.710536964314037,
+ ],
+ },
+ Object {
+ "x": Array [
+ 3.1338629792147654,
+ 6.267725958429531,
+ ],
+ },
+ Object {
+ "x": Array [
+ 3.3864586092521307,
+ 6.772917218504261,
+ ],
+ },
+ Object {
+ "x": Array [
+ 3.612346872127128,
+ 7.224693744254256,
+ ],
+ },
+ Object {
+ "x": Array [
+ 3.8121422945696497,
+ 7.624284589139299,
+ ],
+ },
+ Object {
+ "x": Array [
+ 3.987280262966515,
+ 7.97456052593303,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.139663334891849,
+ 8.279326669783698,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.271416562697019,
+ 8.542833125394038,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.3847222536079205,
+ 8.769444507215841,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.481712093108238,
+ 8.963424186216477,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.5644002344658725,
+ 9.128800468931745,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.634645239535937,
+ 9.269290479071874,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.694131976482419,
+ 9.388263952964838,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.7443669934018855,
+ 9.488733986803771,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.786682687952328,
+ 9.573365375904656,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.822246931023949,
+ 9.644493862047899,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.852075790980104,
+ 9.704151581960208,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.877047730692452,
+ 9.754095461384903,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.89791817836336,
+ 9.79583635672672,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.915333754930874,
+ 9.830667509861748,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.929845713484313,
+ 9.859691426968626,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.941922338097189,
+ 9.883844676194379,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.951960182252786,
+ 9.903920364505572,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.96029411666545,
+ 9.9205882333309,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.967206214779832,
+ 9.934412429559664,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.972933540468227,
+ 9.945867080936454,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.97767492302411,
+ 9.95534984604822,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.981596814298641,
+ 9.963193628597281,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.984838325266105,
+ 9.96967665053221,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.9875155369753035,
+ 9.975031073950607,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.989725175561048,
+ 9.979450351122097,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.991547734030691,
+ 9.983095468061382,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.993050115788252,
+ 9.986100231576504,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.994287866909378,
+ 9.988575733818756,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.995027593476066,
+ 9.990614112845385,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.995027593476066,
+ 9.992291713056268,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.995027593476066,
+ 9.99367173585381,
+ ],
+ },
+ Object {
+ "x": Array [
+ 4.995027593476066,
+ 9.994806458630702,
+ ],
+ },
+ Object {
+ "x": Array [
+ 5,
+ 10,
+ ],
+ },
+]
+`;
+
+exports[`Controller the "loop" prop can be combined with the "reverse" prop 1`] = `
+Array [
+ 0.33334,
+ 0.66668,
+ 1,
+ 0.66666,
+ 0.33331999999999995,
+ 0,
+ 0.33334,
+ 0.66668,
+ 1,
+]
+`;
+
+exports[`Controller when the "to" prop is an async function acts strangely without the "from" prop 1`] = `
+Array [
+ Object {
+ "x": 1.0226348433078583,
+ },
+ Object {
+ "x": 1.0761552889499395,
+ },
+ Object {
+ "x": 1.1472729545687759,
+ },
+ Object {
+ "x": 1.226793329378144,
+ },
+ Object {
+ "x": 1.3085053191603682,
+ },
+ Object {
+ "x": 1.3883524421610094,
+ },
+ Object {
+ "x": 1.4638171205392543,
+ },
+ Object {
+ "x": 1.5334657448036912,
+ },
+ Object {
+ "x": 1.5966146523130327,
+ },
+ Object {
+ "x": 1.6530867180317823,
+ },
+ Object {
+ "x": 1.7030355736424132,
+ },
+ Object {
+ "x": 1.74682006574163,
+ },
+ Object {
+ "x": 1.7849158337229636,
+ },
+ Object {
+ "x": 1.817854140674256,
+ },
+ Object {
+ "x": 1.8461805634019814,
+ },
+ Object {
+ "x": 1.870428023277061,
+ },
+ Object {
+ "x": 1.8911000586164695,
+ },
+ Object {
+ "x": 1.9086613098839855,
+ },
+ Object {
+ "x": 1.9235329941206056,
+ },
+ Object {
+ "x": 1.9360917483504718,
+ },
+ Object {
+ "x": 1.9466706719880824,
+ },
+ Object {
+ "x": 1.9555617327559878,
+ },
+ Object {
+ "x": 1.9630189477450264,
+ },
+ Object {
+ "x": 1.9692619326731133,
+ },
+ Object {
+ "x": 1.9744795445908405,
+ },
+ Object {
+ "x": 1.9788334387327189,
+ },
+ Object {
+ "x": 1.9824614283710786,
+ },
+ Object {
+ "x": 1.9854805845242978,
+ },
+ Object {
+ "x": 1.9879900455631967,
+ },
+ Object {
+ "x": 1.9900735291663625,
+ },
+ Object {
+ "x": 1.991801553694958,
+ },
+ Object {
+ "x": 1.9932333851170567,
+ },
+ Object {
+ "x": 1.9944187307560275,
+ },
+ Object {
+ "x": 1.9953992035746602,
+ },
+ Object {
+ "x": 1.9962095813165261,
+ },
+ Object {
+ "x": 1.9968788842438259,
+ },
+ Object {
+ "x": 1.997431293890262,
+ },
+ Object {
+ "x": 1.9978869335076728,
+ },
+ Object {
+ "x": 1.998262528947063,
+ },
+ Object {
+ "x": 1.9985719667273445,
+ },
+ Object {
+ "x": 1.9988267641056732,
+ },
+ Object {
+ "x": 2,
+ },
+]
+`;
diff --git a/packages/core/src/__snapshots__/SpringValue.test.ts.snap b/packages/core/src/__snapshots__/SpringValue.test.ts.snap
new file mode 100644
index 0000000000..3dd280963d
--- /dev/null
+++ b/packages/core/src/__snapshots__/SpringValue.test.ts.snap
@@ -0,0 +1,295 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SpringValue can animate a number 1`] = `
+Array [
+ 0.100002,
+ 0.200004,
+ 0.300006,
+ 0.400008,
+ 0.50001,
+ 0.600012,
+ 0.700014,
+ 0.800016,
+ 0.900018,
+ 1,
+]
+`;
+
+exports[`SpringValue can animate a string 1`] = `
+Array [
+ "1.00002px 2.00004px",
+ "2.00004px 4.00008px",
+ "3.00006px 6.00012px",
+ "4.00008px 8.00016px",
+ "5.0001px 10.0002px",
+ "6.00012px 12.00024px",
+ "7.00014px 14.00028px",
+ "8.00016px 16.00032px",
+ "9.00018px 18.00036px",
+ "10px 20px",
+]
+`;
+
+exports[`SpringValue can animate an array of numbers 1`] = `
+Array [
+ Array [
+ 1.00002,
+ 2.00004,
+ ],
+ Array [
+ 2.00004,
+ 4.00008,
+ ],
+ Array [
+ 3.00006,
+ 6.00012,
+ ],
+ Array [
+ 4.00008,
+ 8.00016,
+ ],
+ Array [
+ 5.0001,
+ 10.0002,
+ ],
+ Array [
+ 6.00012,
+ 12.00024,
+ ],
+ Array [
+ 7.00014,
+ 14.00028,
+ ],
+ Array [
+ 8.00016,
+ 16.00032,
+ ],
+ Array [
+ 9.00018,
+ 18.00036,
+ ],
+ Array [
+ 10,
+ 20,
+ ],
+]
+`;
+
+exports[`SpringValue can have an animated string as its target 1`] = `
+Array [
+ "rgba(255, 255, 0, 1)",
+ "rgba(255, 229, 0, 1)",
+ "rgba(255, 204, 0, 1)",
+ "rgba(255, 178, 0, 1)",
+ "rgba(255, 153, 0, 1)",
+ "rgba(255, 127, 0, 1)",
+ "rgba(255, 102, 0, 1)",
+ "rgba(255, 76, 0, 1)",
+ "rgba(255, 51, 0, 1)",
+ "rgba(255, 25, 0, 1)",
+ "red",
+]
+`;
+
+exports[`SpringValue the "loop" prop can be combined with the "reverse" prop 1`] = `
+Array [
+ 0.33334,
+ 0.66668,
+ 1,
+ 0.66666,
+ 0.33331999999999995,
+ 0,
+ 0.33334,
+ 0.66668,
+ 1,
+]
+`;
+
+exports[`SpringValue the "loop" prop resets the animation once finished 1`] = `
+Array [
+ 0.33334,
+ 0.66668,
+ 1,
+]
+`;
+
+exports[`SpringValue when "reverse" prop is true swaps the "to" and "from" props 1`] = `
+Array [
+ 0.9773651566921419,
+ 0.9238447110500605,
+ 0.852727045431224,
+ 0.7732066706218559,
+ 0.6914946808396315,
+ 0.6116475578389905,
+ 0.5361828794607453,
+ 0.4665342551963084,
+ 0.4033853476869672,
+ 0.3469132819682177,
+ 0.29696442635758724,
+ 0.2531799342583708,
+ 0.21508416627703766,
+ 0.18214585932574526,
+ 0.15381943659801983,
+ 0.1295719767229399,
+ 0.10889994138353111,
+ 0.09133869011601468,
+ 0.07646700587939456,
+ 0.06390825164952882,
+ 0.05332932801191841,
+ 0.0444382672440135,
+ 0.03698105225497496,
+ 0.030738067326888108,
+ 0.02552045540916056,
+ 0.02116656126728198,
+ 0.01753857162892271,
+ 0.014519415475703344,
+ 0.012009954436804516,
+ 0.009926470833638602,
+ 0.008198446305043062,
+ 0.006766614882944518,
+ 0.005581269243973591,
+ 0.0046007964253405855,
+ 0.0037904186834741348,
+ 0.003121115756173977,
+ 0.0025687061097373425,
+ 0.002113066492326755,
+ 0.0017374710529361444,
+ 0.0014280332726546588,
+ 0.001173235894326376,
+ 0,
+]
+`;
+
+exports[`SpringValue when "reverse" prop is true works when "from" was set by an earlier update 1`] = `
+Array [
+ 0.9773651566921419,
+ 0.9238447110500605,
+ 0.852727045431224,
+ 0.7732066706218559,
+ 0.6914946808396315,
+ 0.6116475578389905,
+ 0.5361828794607453,
+ 0.4665342551963084,
+ 0.4033853476869672,
+ 0.3469132819682177,
+ 0.29696442635758724,
+ 0.2531799342583708,
+ 0.21508416627703766,
+ 0.18214585932574526,
+ 0.15381943659801983,
+ 0.1295719767229399,
+ 0.10889994138353111,
+ 0.09133869011601468,
+ 0.07646700587939456,
+ 0.06390825164952882,
+ 0.05332932801191841,
+ 0.0444382672440135,
+ 0.03698105225497496,
+ 0.030738067326888108,
+ 0.02552045540916056,
+ 0.02116656126728198,
+ 0.01753857162892271,
+ 0.014519415475703344,
+ 0.012009954436804516,
+ 0.009926470833638602,
+ 0.008198446305043062,
+ 0.006766614882944518,
+ 0.005581269243973591,
+ 0.0046007964253405855,
+ 0.0037904186834741348,
+ 0.003121115756173977,
+ 0.0025687061097373425,
+ 0.002113066492326755,
+ 0.0017374710529361444,
+ 0.0014280332726546588,
+ 0.001173235894326376,
+ 0,
+]
+`;
+
+exports[`SpringValue when "reverse" prop is true works when "to" and "from" were set by an earlier update 1`] = `
+Array [
+ 0.022634843307857987,
+ 0.07615528894993949,
+ 0.14727295456877587,
+ 0.226793329378144,
+ 0.30850531916036833,
+ 0.3883524421610094,
+ 0.4638171205392546,
+ 0.5334657448036914,
+ 0.5739798090051748,
+ 0.5769314290818427,
+ 0.5557626190736369,
+ 0.5200267363634852,
+ 0.4764105145625939,
+ 0.4295016985132453,
+ 0.38236344286272556,
+ 0.3369622784733687,
+ 0.29448540630343617,
+ 0.25557459185220316,
+ 0.22049742047819276,
+ 0.18927168260884208,
+ 0.16175483826511933,
+ 0.1377075920817319,
+ 0.11683838434304501,
+ 0.0988339093960519,
+ 0.08337948597437063,
+ 0.07017212884873279,
+ 0.05892843425047188,
+ 0.049388836173825494,
+ 0.041319373575113914,
+ 0.03451179641037492,
+ 0.028782605949931907,
+ 0.023971452443943598,
+ 0.019939186165186966,
+ 0.016565764841941392,
+ 0.013748152945448568,
+ 0.011398299719529353,
+ 0.009441248327067157,
+ 0.007813404341311832,
+ 0.006460975252106907,
+ 0.005338581610289848,
+ 0.004408033349647206,
+ 0.00363726055737456,
+ 0.0029993856652006856,
+ 0.0024719230850121722,
+ 0.002036092265339304,
+ 0.0016762306751292224,
+ 0.0013792940991652848,
+ 0.001134432694629231,
+ 0.0009326324005005715,
+ 0.0007664124372832261,
+ 0.0006295707368567502,
+ 0,
+]
+`;
+
+exports[`SpringValue when "to" prop equals current value avoids interrupting an active animation 1`] = `
+Array [
+ 0.022634843307857987,
+ 0.05403278177365278,
+ 0.07284142865128296,
+ 0.0828538750595177,
+ 0.086845421255966,
+ 0.08683009255947671,
+ 0.0842549750547836,
+ 0.08014705211134462,
+ 0.0752238210530826,
+ 0.06997634488902708,
+ 0.06473137113972319,
+ 0.059697592148462514,
+ 0.05499992314855915,
+ 0.050704753857468414,
+ 0.04683842305057004,
+ 0.04340062433783876,
+ 0.040374037257053644,
+ 0.03773116146592327,
+ 0.03543909060575714,
+ 0.033462778841422576,
+ 0.031767213683302126,
+ 0.030318803092499735,
+ 0.029086205080955534,
+ 0.028040767912793738,
+ 0.022634843307857987,
+]
+`;
diff --git a/packages/core/src/components/Spring.tsx b/packages/core/src/components/Spring.tsx
new file mode 100644
index 0000000000..110dd44efe
--- /dev/null
+++ b/packages/core/src/components/Spring.tsx
@@ -0,0 +1,27 @@
+import { NoInfer, UnknownProps } from '@react-spring/types'
+import { useSpring, UseSpringProps } from '../hooks/useSpring'
+import { SpringValues, SpringToFn, SpringChain } from '../types'
+
+export type SpringComponentProps<
+ State extends object = UnknownProps
+> = unknown &
+ UseSpringProps & {
+ children: (values: SpringValues) => JSX.Element | null
+ }
+
+// Infer state from "from" object prop.
+export function Spring(
+ props: {
+ from: State
+ to?: SpringChain> | SpringToFn>
+ } & Omit>, 'from' | 'to'>
+): JSX.Element | null
+
+// Infer state from "to" object prop.
+export function Spring(
+ props: { to: State } & Omit>, 'to'>
+): JSX.Element | null
+
+export function Spring({ children, ...props }: any) {
+ return children(useSpring(props))
+}
diff --git a/packages/core/src/components/Trail.tsx b/packages/core/src/components/Trail.tsx
new file mode 100644
index 0000000000..a87a01726e
--- /dev/null
+++ b/packages/core/src/components/Trail.tsx
@@ -0,0 +1,29 @@
+import { ReactNode } from 'react'
+import { NoInfer, Falsy } from '@react-spring/types'
+import { is } from '@react-spring/shared'
+
+import { Valid } from '../types/common'
+import { PickAnimated, SpringValues } from '../types'
+import { UseSpringProps } from '../hooks/useSpring'
+import { useTrail } from '../hooks/useTrail'
+
+export type TrailComponentProps- = unknown &
+ UseSpringProps & {
+ items: readonly Item[]
+ children: (
+ item: NoInfer
- ,
+ index: number
+ ) => ((values: SpringValues>) => ReactNode) | Falsy
+ }
+
+export function Trail
- >({
+ items,
+ children,
+ ...props
+}: Props & Valid>) {
+ const trails: any[] = useTrail(items.length, props)
+ return items.map((item, index) => {
+ const result = children(item, index)
+ return is.fun(result) ? result(trails[index]) : result
+ })
+}
diff --git a/packages/core/src/components/Transition.tsx b/packages/core/src/components/Transition.tsx
new file mode 100644
index 0000000000..60b605dce9
--- /dev/null
+++ b/packages/core/src/components/Transition.tsx
@@ -0,0 +1,20 @@
+import { Valid } from '../types/common'
+import { TransitionComponentProps } from '../types'
+import { useTransition } from '../hooks'
+
+export function Transition<
+ Item extends any,
+ Props extends TransitionComponentProps
-
+>(
+ props:
+ | TransitionComponentProps
-
+ | (Props & Valid>)
+): JSX.Element
+
+export function Transition({
+ items,
+ children,
+ ...props
+}: TransitionComponentProps) {
+ return useTransition(items, props)(children)
+}
diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts
new file mode 100644
index 0000000000..acabf01c4b
--- /dev/null
+++ b/packages/core/src/components/index.ts
@@ -0,0 +1,3 @@
+export * from './Spring'
+export * from './Trail'
+export * from './Transition'
diff --git a/src/shared/constants.ts b/packages/core/src/constants.ts
similarity index 86%
rename from src/shared/constants.ts
rename to packages/core/src/constants.ts
index 5d40b5cd3d..39a2833ec7 100644
--- a/src/shared/constants.ts
+++ b/packages/core/src/constants.ts
@@ -1,3 +1,4 @@
+// The `mass` prop defaults to 1
export const config = {
default: { tension: 170, friction: 26 },
gentle: { tension: 120, friction: 14 },
@@ -5,4 +6,4 @@ export const config = {
stiff: { tension: 210, friction: 20 },
slow: { tension: 280, friction: 60 },
molasses: { tension: 280, friction: 120 },
-}
+} as const
diff --git a/packages/core/src/globals.ts b/packages/core/src/globals.ts
new file mode 100644
index 0000000000..de1b6cafd7
--- /dev/null
+++ b/packages/core/src/globals.ts
@@ -0,0 +1,17 @@
+import {
+ Globals,
+ frameLoop,
+ createStringInterpolator,
+} from '@react-spring/shared'
+import { Interpolation } from './Interpolation'
+
+// Sane defaults
+Globals.assign({
+ createStringInterpolator,
+ to: (source, args) => new Interpolation(source, args),
+})
+
+export { Globals }
+
+/** Advance all animations by the given time */
+export const update = frameLoop.advance
diff --git a/packages/core/src/helpers.test.ts b/packages/core/src/helpers.test.ts
new file mode 100644
index 0000000000..80015c879b
--- /dev/null
+++ b/packages/core/src/helpers.test.ts
@@ -0,0 +1,55 @@
+import { inferTo } from './helpers'
+import { ReservedProps } from './types/props'
+
+describe('helpers', () => {
+ it('interpolateTo', () => {
+ const forwardProps = {
+ result: 'ok',
+ }
+ const restProps = {
+ from: 'from',
+ config: 'config',
+ onStart: 'onStart',
+ }
+ const excludeProps: Required = {
+ children: undefined,
+ config: undefined,
+ from: undefined,
+ to: undefined,
+ ref: undefined,
+ loop: undefined,
+ reset: undefined,
+ pause: undefined,
+ cancel: undefined,
+ reverse: undefined,
+ immediate: undefined,
+ default: undefined,
+ delay: undefined,
+ items: undefined,
+ trail: undefined,
+ sort: undefined,
+ expires: undefined,
+ initial: undefined,
+ enter: undefined,
+ leave: undefined,
+ update: undefined,
+ onProps: undefined,
+ onStart: undefined,
+ onChange: undefined,
+ onRest: undefined,
+ onResolve: undefined,
+ onDestroyed: undefined,
+ }
+ expect(
+ inferTo({
+ ...forwardProps,
+ ...restProps,
+ ...excludeProps,
+ })
+ ).toMatchObject({
+ to: forwardProps,
+ ...restProps,
+ ...excludeProps,
+ })
+ })
+})
diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts
new file mode 100644
index 0000000000..a588ca5540
--- /dev/null
+++ b/packages/core/src/helpers.ts
@@ -0,0 +1,219 @@
+import {
+ is,
+ toArray,
+ eachProp,
+ getFluidValue,
+ isAnimatedString,
+ FluidValue,
+ Globals as G,
+} from '@react-spring/shared'
+import { AnyFn, OneOrMore, Lookup } from '@react-spring/types'
+import { ReservedProps, ForwardProps, InferTo } from './types'
+import type { Controller } from './Controller'
+import type { SpringRef } from './SpringRef'
+
+export function callProp(
+ value: T,
+ ...args: T extends AnyFn ? Parameters : unknown[]
+): T extends AnyFn ? U : T {
+ return is.fun(value) ? value(...args) : value
+}
+
+/** Try to coerce the given value into a boolean using the given key */
+export const matchProp = (
+ value: boolean | OneOrMore | ((key: any) => boolean) | undefined,
+ key: string | undefined
+) =>
+ value === true ||
+ !!(
+ key &&
+ value &&
+ (is.fun(value) ? value(key) : toArray(value).includes(key))
+ )
+
+export const resolveProp = (
+ prop: T | Lookup | undefined,
+ key: string | undefined
+) => (is.obj(prop) ? key && (prop as any)[key] : prop)
+
+export const concatFn = (first: T | undefined, last: T) =>
+ first ? (...args: Parameters) => (first(...args), last(...args)) : last
+
+/** Returns `true` if the given prop is having its default value set. */
+export const hasDefaultProp = (props: T, key: keyof T) =>
+ !is.und(getDefaultProp(props, key))
+
+/** Get the default value being set for the given `key` */
+export const getDefaultProp = (
+ props: T,
+ key: P
+): T[P] =>
+ props.default === true
+ ? props[key]
+ : props.default
+ ? props.default[key]
+ : undefined
+
+const noopTransform = (value: any) => value
+
+/**
+ * Extract the default props from an update.
+ *
+ * When the `default` prop is falsy, this function still behaves as if
+ * `default: true` was used. The `default` prop is always respected when
+ * truthy.
+ */
+export const getDefaultProps = (
+ props: Lookup,
+ transform: (value: any, key: string) => any = noopTransform
+): T => {
+ let keys: readonly string[] = DEFAULT_PROPS
+ if (props.default && props.default !== true) {
+ props = props.default
+ keys = Object.keys(props)
+ }
+ const defaults: any = {}
+ for (const key of keys) {
+ const value = transform(props[key], key)
+ if (!is.und(value)) {
+ defaults[key] = value
+ }
+ }
+ return defaults
+}
+
+/**
+ * These props are implicitly used as defaults when defined in a
+ * declarative update (eg: render-based) or any update with `default: true`.
+ *
+ * Use `default: {}` or `default: false` to opt-out of these implicit defaults
+ * for any given update.
+ *
+ * Note: These are not the only props with default values. For example, the
+ * `pause`, `cancel`, and `immediate` props. But those must be updated with
+ * the object syntax (eg: `default: { immediate: true }`).
+ */
+export const DEFAULT_PROPS = [
+ 'config',
+ 'onProps',
+ 'onStart',
+ 'onChange',
+ 'onPause',
+ 'onResume',
+ 'onRest',
+] as const
+
+const RESERVED_PROPS: {
+ [key: string]: 1 | undefined
+} = {
+ config: 1,
+ from: 1,
+ to: 1,
+ ref: 1,
+ loop: 1,
+ reset: 1,
+ pause: 1,
+ cancel: 1,
+ reverse: 1,
+ immediate: 1,
+ default: 1,
+ delay: 1,
+ onProps: 1,
+ onStart: 1,
+ onChange: 1,
+ onPause: 1,
+ onResume: 1,
+ onRest: 1,
+ onResolve: 1,
+
+ // Transition props
+ items: 1,
+ trail: 1,
+ sort: 1,
+ expires: 1,
+ initial: 1,
+ enter: 1,
+ update: 1,
+ leave: 1,
+ children: 1,
+ onDestroyed: 1,
+
+ // Internal props
+ keys: 1,
+ callId: 1,
+ parentId: 1,
+}
+
+/**
+ * Extract any properties whose keys are *not* reserved for customizing your
+ * animations. All hooks use this function, which means `useTransition` props
+ * are reserved for `useSpring` calls, etc.
+ */
+function getForwardProps(
+ props: Props
+): ForwardProps | undefined {
+ const forward: any = {}
+
+ let count = 0
+ eachProp(props, (value, prop) => {
+ if (!RESERVED_PROPS[prop]) {
+ forward[prop] = value
+ count++
+ }
+ })
+
+ if (count) {
+ return forward
+ }
+}
+
+/**
+ * Clone the given `props` and move all non-reserved props
+ * into the `to` prop.
+ */
+export function inferTo(props: T): InferTo {
+ const to = getForwardProps(props)
+ if (to) {
+ const out: any = { to }
+ eachProp(props, (val, key) => key in to || (out[key] = val))
+ return out
+ }
+ return { ...props } as any
+}
+
+// Compute the goal value, converting "red" to "rgba(255, 0, 0, 1)" in the process
+export function computeGoal(value: T | FluidValue): T {
+ value = getFluidValue(value)
+ return is.arr(value)
+ ? value.map(computeGoal)
+ : isAnimatedString(value)
+ ? (G.createStringInterpolator({
+ range: [0, 1],
+ output: [value, value] as any,
+ })(1) as any)
+ : value
+}
+
+export function hasProps(props: object) {
+ for (const _ in props) return true
+ return false
+}
+
+export function isAsyncTo(to: any) {
+ return is.fun(to) || (is.arr(to) && is.obj(to[0]))
+}
+
+/** Detach `ctrl` from `ctrl.ref` and (optionally) the given `ref` */
+export function detachRefs(ctrl: Controller, ref?: SpringRef) {
+ ctrl.ref?.delete(ctrl)
+ ref?.delete(ctrl)
+}
+
+/** Replace `ctrl.ref` with the given `ref` (if defined) */
+export function replaceRef(ctrl: Controller, ref?: SpringRef) {
+ if (ref && ctrl.ref !== ref) {
+ ctrl.ref?.delete(ctrl)
+ ref.add(ctrl)
+ ctrl.ref = ref
+ }
+}
diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts
new file mode 100644
index 0000000000..38e5ac4509
--- /dev/null
+++ b/packages/core/src/hooks/index.ts
@@ -0,0 +1,6 @@
+export * from './useChain'
+export * from './useSpring'
+export * from './useSprings'
+export * from './useSpringRef'
+export * from './useTrail'
+export * from './useTransition'
diff --git a/packages/core/src/hooks/useChain.ts b/packages/core/src/hooks/useChain.ts
new file mode 100644
index 0000000000..09a041c87a
--- /dev/null
+++ b/packages/core/src/hooks/useChain.ts
@@ -0,0 +1,54 @@
+import { useLayoutEffect } from 'react-layout-effect'
+import { each } from '@react-spring/shared'
+import { SpringRef } from '../SpringRef'
+import { callProp } from '../helpers'
+
+export function useChain(
+ refs: ReadonlyArray,
+ timeSteps?: number[],
+ timeFrame = 1000
+) {
+ useLayoutEffect(() => {
+ if (timeSteps) {
+ let prevDelay = 0
+ each(refs, (ref, i) => {
+ const controllers = ref.current
+ if (controllers.length) {
+ let delay = timeFrame * timeSteps[i]
+
+ // Use the previous delay if none exists.
+ if (isNaN(delay)) delay = prevDelay
+ else prevDelay = delay
+
+ each(controllers, ctrl => {
+ each(ctrl.queue, props => {
+ props.delay = key => delay + callProp(props.delay || 0, key)
+ })
+ ctrl.start()
+ })
+ }
+ })
+ } else {
+ let p: Promise = Promise.resolve()
+ each(refs, ref => {
+ const controllers = ref.current
+ if (controllers.length) {
+ // Take the queue of each controller
+ const queues = controllers.map(ctrl => {
+ const q = ctrl.queue
+ ctrl.queue = []
+ return q
+ })
+
+ // Apply the queue when the previous ref stops animating
+ p = p.then(() => {
+ each(controllers, (ctrl, i) =>
+ each(queues[i] || [], update => ctrl.queue.push(update))
+ )
+ return ref.start()
+ })
+ }
+ })
+ }
+ })
+}
diff --git a/packages/core/src/hooks/useSpring.test.tsx b/packages/core/src/hooks/useSpring.test.tsx
new file mode 100644
index 0000000000..f3577b8fa5
--- /dev/null
+++ b/packages/core/src/hooks/useSpring.test.tsx
@@ -0,0 +1,154 @@
+import * as React from 'react'
+import { render, RenderResult } from '@testing-library/react'
+import { is } from '@react-spring/shared'
+import { Lookup } from '@react-spring/types'
+import { SpringContext } from '../SpringContext'
+import { SpringValue } from '../SpringValue'
+import { SpringRef } from '../SpringRef'
+import { useSpring } from './useSpring'
+
+describe('useSpring', () => {
+ let springs: Lookup
+ let ref: SpringRef
+
+ // Call the "useSpring" hook and update local variables.
+ const [update, context] = createUpdater(({ args }) => {
+ const result = useSpring(...args)
+ if (is.fun(args[0]) || args.length == 2) {
+ springs = result[0] as any
+ ref = result[1]
+ } else {
+ springs = result as any
+ ref = undefined as any
+ }
+ return null
+ })
+
+ describe('when only a props object is passed', () => {
+ it('is updated every render', () => {
+ update({ x: 0 })
+ expect(springs.x.goal).toBe(0)
+
+ update({ x: 1 })
+ expect(springs.x.goal).toBe(1)
+ })
+ it('does not return a ref', () => {
+ update({ x: 0 })
+ expect(ref).toBeUndefined()
+ })
+
+ describe('when SpringContext has "pause={false}"', () => {
+ it('stays paused if last rendered with "pause: true"', () => {
+ const props = { from: { t: 0 }, to: { t: 1 } }
+
+ // Paused by context.
+ context.set({ pause: true })
+ update({ ...props, pause: false })
+ expect(springs.t.isPaused).toBeTruthy()
+
+ // Paused by props and context.
+ update({ ...props, pause: true })
+ expect(springs.t.isPaused).toBeTruthy()
+
+ // Paused by props.
+ context.set({ pause: false })
+ expect(springs.t.isPaused).toBeTruthy()
+
+ // Resumed.
+ update({ ...props, pause: false })
+ expect(springs.t.isPaused).toBeFalsy()
+ })
+ })
+ })
+
+ describe('when both a props object and a deps array are passed', () => {
+ it('is updated only when a dependency changes', () => {
+ update({ x: 0 }, [1])
+ expect(springs.x.goal).toBe(0)
+
+ update({ x: 1 }, [1])
+ expect(springs.x.goal).toBe(0)
+
+ update({ x: 1 }, [2])
+ expect(springs.x.goal).toBe(1)
+ })
+ it('returns a ref', () => {
+ update({ x: 0 }, [1])
+ expect(ref).toBeInstanceOf(SpringRef)
+ })
+ })
+
+ describe('when only a props function is passed', () => {
+ it('is never updated on render', () => {
+ update(() => ({ x: 0 }))
+ expect(springs.x.goal).toBe(0)
+
+ update(() => ({ x: 1 }))
+ expect(springs.x.goal).toBe(0)
+ })
+ it('returns a ref', () => {
+ update(() => ({ x: 0 }))
+ expect(ref).toBeInstanceOf(SpringRef)
+ })
+ })
+
+ describe('when both a props function and a deps array are passed', () => {
+ it('is updated when a dependency changes', () => {
+ update(() => ({ x: 0 }), [1])
+ expect(springs.x.goal).toBe(0)
+
+ update(() => ({ x: 1 }), [1])
+ expect(springs.x.goal).toBe(0)
+
+ update(() => ({ x: 1 }), [2])
+ expect(springs.x.goal).toBe(1)
+ })
+ it('returns a ref', () => {
+ update(() => ({ x: 0 }), [1])
+ expect(ref).toBeInstanceOf(SpringRef)
+ })
+ })
+})
+
+interface TestContext extends SpringContext {
+ set(values: SpringContext): void
+}
+
+function createUpdater(Component: React.ComponentType<{ args: [any, any?] }>) {
+ let prevElem: JSX.Element | undefined
+ let result: RenderResult | undefined
+
+ const context: TestContext = {
+ set(values) {
+ Object.assign(this, values)
+ if (prevElem) {
+ renderWithContext(prevElem)
+ }
+ },
+ }
+ // Ensure `context.set` is ignored.
+ Object.defineProperty(context, 'set', {
+ value: context.set,
+ enumerable: false,
+ })
+
+ afterEach(() => {
+ result = prevElem = undefined
+ for (const key in context) {
+ delete (context as any)[key]
+ }
+ })
+
+ function renderWithContext(elem: JSX.Element) {
+ const wrapped = {elem}
+ if (result) result.rerender(wrapped)
+ else result = render(wrapped)
+ return result
+ }
+
+ type Args = Parameters
+ const update = (...args: [Args[0], Args[1]?]) =>
+ renderWithContext((prevElem = ))
+
+ return [update, context] as const
+}
diff --git a/packages/core/src/hooks/useSpring.ts b/packages/core/src/hooks/useSpring.ts
new file mode 100644
index 0000000000..ed686c9c01
--- /dev/null
+++ b/packages/core/src/hooks/useSpring.ts
@@ -0,0 +1,65 @@
+import { Remap } from '@react-spring/types'
+import { is } from '@react-spring/shared'
+
+import { ControllerUpdate, PickAnimated, SpringValues } from '../types'
+import { Valid } from '../types/common'
+import { SpringRef } from '../SpringRef'
+import { useSprings } from './useSprings'
+
+/**
+ * The props that `useSpring` recognizes.
+ */
+export type UseSpringProps = unknown &
+ PickAnimated extends infer State
+ ? Remap<
+ ControllerUpdate & {
+ /**
+ * Used to access the imperative API.
+ *
+ * When defined, the render animation won't auto-start.
+ */
+ ref?: SpringRef
+ }
+ >
+ : never
+
+/**
+ * The `props` function is only called on the first render, unless
+ * `deps` change (when defined). State is inferred from forward props.
+ */
+export function useSpring(
+ props:
+ | Function
+ | (() => (Props & Valid>) | UseSpringProps),
+ deps?: readonly any[] | undefined
+): PickAnimated extends infer State
+ ? [SpringValues, SpringRef]
+ : never
+
+/**
+ * Updated on every render, with state inferred from forward props.
+ */
+export function useSpring