diff --git a/.claude/commands/fix.md b/.claude/commands/fix.md index 7c18d2e6b1..4d9bd79cdc 100644 --- a/.claude/commands/fix.md +++ b/.claude/commands/fix.md @@ -20,5 +20,5 @@ git worktree add ../${REPO_NAME}-$ARGUMENTS -b $ARGUMENTS Then tell me to close Claude and reopen with: ```bash -cd ../-$ARGUMENTS && claude +cd ../-$ARGUMENTS && claude --dangerously-skip-permissions ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index c686561f7c..597a32ed74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.28.2] 2026-01-21 + +### Added + +- Add default value type `px` for `fontSize`. + +### Fixed + +- Removed default value type from `radius`. +- Ensure `LazyMotion` animates initial state even when state has changed before Motion is loaded. + +## [12.28.1] 2026-01-21 + +### Fixed + +- Ensure `scale: "0%"` isn't treated as default value. + ## [12.28.0] 2026-01-20 ### Added diff --git a/dev/html/package.json b/dev/html/package.json index c563403b50..0616625fcc 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.28.0", + "version": "12.28.2", "type": "module", "scripts": { "dev": "vite", @@ -10,9 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.28.0", - "motion": "^12.28.0", - "motion-dom": "^12.28.0", + "framer-motion": "^12.28.2", + "motion": "^12.28.2", + "motion-dom": "^12.28.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/next/package.json b/dev/next/package.json index 56ecd07628..2daed67612 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.28.0", + "version": "12.28.2", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.28.0", + "motion": "^12.28.2", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 48bed74ebf..7c48473384 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.28.0", + "version": "12.28.2", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.28.0", + "motion": "^12.28.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 2b4ce9f0bb..af179b7d02 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.28.0", + "version": "12.28.2", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.28.0", + "framer-motion": "^12.28.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/lazy-motion-fast-state.tsx b/dev/react/src/tests/lazy-motion-fast-state.tsx new file mode 100644 index 0000000000..3e22176dd9 --- /dev/null +++ b/dev/react/src/tests/lazy-motion-fast-state.tsx @@ -0,0 +1,58 @@ +import { m, LazyMotion, domAnimation } from "framer-motion" +import { useState, useEffect, useRef } from "react" + +/** + * Test for GitHub issue #2759 + * When LazyMotion features load asynchronously and state changes occur before + * features load, the enter animation should still fire (not snap to final value). + */ + +const variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +} + +export const App = () => { + const [isVisible, setIsVisible] = useState(false) + const boxRef = useRef(null) + + // Simulate state change that occurs before features load + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(true) + }, 5) // State changes after 5ms + return () => clearTimeout(timer) + }, []) + + return ( + + new Promise((resolve) => { + // Features load after 50ms (longer than the 5ms state change) + setTimeout(() => { + resolve(domAnimation) + }, 50) + }) + } + > + { + if (boxRef.current) { + boxRef.current.dataset.animationComplete = "true" + } + }} + style={{ + width: 100, + height: 100, + background: "red", + }} + /> + + ) +} diff --git a/lerna.json b/lerna.json index a4f79db0b5..41bb16df26 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "12.28.0", + "version": "12.28.2", "packages": [ "packages/*", "dev/*" diff --git a/packages/framer-motion/cypress/integration/lazy-motion-fast-state.ts b/packages/framer-motion/cypress/integration/lazy-motion-fast-state.ts new file mode 100644 index 0000000000..073aa1b4c8 --- /dev/null +++ b/packages/framer-motion/cypress/integration/lazy-motion-fast-state.ts @@ -0,0 +1,13 @@ +describe("LazyMotion with fast state changes", () => { + it("animates when state changes before features load", () => { + cy.visit("?test=lazy-motion-fast-state") + .wait(300) // Wait for features to load and animation to complete + .get("#box") + .should(([$element]: any) => { + // Verify the animation completed + expect($element.dataset.animationComplete).to.equal("true") + // Verify the element is visible (opacity: 1) + expect(getComputedStyle($element).opacity).to.equal("1") + }) + }) +}) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 63b0909fd1..983b4fd1eb 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.28.0", + "version": "12.28.2", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -88,7 +88,7 @@ "measure": "rollup -c ./rollup.size.config.mjs" }, "dependencies": { - "motion-dom": "^12.28.0", + "motion-dom": "^12.28.2", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, diff --git a/packages/framer-motion/src/motion/utils/use-visual-element.ts b/packages/framer-motion/src/motion/utils/use-visual-element.ts index a76fe01dd5..cd5f081516 100644 --- a/packages/framer-motion/src/motion/utils/use-visual-element.ts +++ b/packages/framer-motion/src/motion/utils/use-visual-element.ts @@ -46,6 +46,12 @@ export function useVisualElement< HTMLElement | SVGElement > | null>(null) + /** + * Track whether the component has been through React's commit phase. + * Used to detect when LazyMotion features load after the component has mounted. + */ + const hasMountedOnce = useRef(false) + /** * If we haven't preloaded a renderer, check to see if we have one lazy-loaded */ @@ -65,6 +71,16 @@ export function useVisualElement< reducedMotionConfig, isSVG, }) + + /** + * If the component has already mounted before features loaded (e.g. via + * LazyMotion with async feature loading), we need to force the initial + * animation to run. Otherwise state changes that occurred before features + * loaded will be lost and the element will snap to its final state. + */ + if (hasMountedOnce.current && visualElementRef.current) { + visualElementRef.current.manuallyAnimateOnMount = true + } } const visualElement = visualElementRef.current @@ -113,6 +129,12 @@ export function useVisualElement< ) useIsomorphicLayoutEffect(() => { + /** + * Track that this component has mounted. This is used to detect when + * LazyMotion features load after the component has already committed. + */ + hasMountedOnce.current = true + if (!visualElement) return isMounted.current = true diff --git a/packages/framer-motion/src/render/html/utils/__tests__/build-styles.test.ts b/packages/framer-motion/src/render/html/utils/__tests__/build-styles.test.ts index 06de8441c1..643e91767a 100644 --- a/packages/framer-motion/src/render/html/utils/__tests__/build-styles.test.ts +++ b/packages/framer-motion/src/render/html/utils/__tests__/build-styles.test.ts @@ -12,6 +12,14 @@ describe("buildHTMLStyles", () => { expect(style).toEqual({ width: "100px" }) }) + test("Builds fontSize with px unit", () => { + const latest = { fontSize: 16 } + const style = {} + build(latest, { style }) + + expect(style).toEqual({ fontSize: "16px" }) + }) + test("Builds vars", () => { const latest = { "--width": 100 } const vars = {} diff --git a/packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts b/packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts index 29f9d9b4a0..07e691cdb4 100644 --- a/packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts +++ b/packages/framer-motion/src/render/html/utils/__tests__/build-transform.test.ts @@ -101,4 +101,11 @@ describe("buildTransform", () => { "translateX(1px) translateY(10px) scale(2) rotate(90deg) rotateZ(190deg)" ) }) + + it("Correctly handles string scale values of zero", () => { + // scale: "0" should produce scale(0), not be treated as default + expect(buildTransform({ scale: "0" }, {})).toBe("scale(0)") + expect(buildTransform({ scaleX: "0" }, {})).toBe("scaleX(0)") + expect(buildTransform({ scaleY: "0" }, {})).toBe("scaleY(0)") + }) }) diff --git a/packages/motion-dom/package.json b/packages/motion-dom/package.json index d60b2c5cc0..0e8c7e3882 100644 --- a/packages/motion-dom/package.json +++ b/packages/motion-dom/package.json @@ -1,6 +1,6 @@ { "name": "motion-dom", - "version": "12.28.0", + "version": "12.28.2", "author": "Matt Perry", "license": "MIT", "repository": "https://github.com/motiondivision/motion", diff --git a/packages/motion-dom/src/animation/waapi/utils/px-values.ts b/packages/motion-dom/src/animation/waapi/utils/px-values.ts index ce04cc35d9..b41cc2f065 100644 --- a/packages/motion-dom/src/animation/waapi/utils/px-values.ts +++ b/packages/motion-dom/src/animation/waapi/utils/px-values.ts @@ -6,7 +6,6 @@ export const pxValues = new Set([ "borderBottomWidth", "borderLeftWidth", "borderRadius", - "radius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", @@ -50,6 +49,8 @@ export const pxValues = new Set([ "marginInline", "marginInlineStart", "marginInlineEnd", + // Typography + "fontSize", // Misc "backgroundPositionX", "backgroundPositionY", diff --git a/packages/motion-dom/src/effects/__tests__/style-effect.test.ts b/packages/motion-dom/src/effects/__tests__/style-effect.test.ts index dae2067a46..310e4bd182 100644 --- a/packages/motion-dom/src/effects/__tests__/style-effect.test.ts +++ b/packages/motion-dom/src/effects/__tests__/style-effect.test.ts @@ -477,4 +477,35 @@ describe("styleEffect", () => { // Verify mixed values are handled correctly expect(element.style.transformOrigin).toBe("50% 100% 0px") }) + + it("correctly handles string scale values of zero", async () => { + const element = document.createElement("div") + + // Create motion values with string scale of zero + const scale = motionValue("0") + const scaleX = motionValue("0") + const scaleY = motionValue("0") + + // Apply style effect + styleEffect(element, { + scale, + scaleX, + scaleY, + }) + + await nextFrame() + + // scale: "0" should produce scale(0), not be treated as default + expect(element.style.transform).toBe("scale(0) scaleX(0) scaleY(0)") + + // Change to non-zero values + scale.set("1") + scaleX.set("1") + scaleY.set("1") + + await nextFrame() + + // All values are now default (1), so transform should be "none" + expect(element.style.transform).toBe("none") + }) }) diff --git a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts index 35b75f1268..88652559e8 100644 --- a/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts +++ b/packages/motion-dom/src/effects/__tests__/svg-effect.test.ts @@ -9,6 +9,27 @@ async function nextFrame() { } describe("svgEffect", () => { + it("sets feMorphology radius as unitless number (issue #2779)", async () => { + const element = document.createElementNS( + "http://www.w3.org/2000/svg", + "feMorphology" + ) + + // Create motion value for radius + const radius = motionValue(4) + + // Apply svg effect + svgEffect(element, { + radius, + }) + + await nextFrame() + + // Verify radius is set as unitless number, not with "px" suffix + // This was the bug - radius was being set as "4px" instead of "4" + expect(element.getAttribute("radius")).toBe("4") + }) + it("sets SVG attributes and styles after svgEffect is applied", async () => { const element = document.createElementNS( "http://www.w3.org/2000/svg", diff --git a/packages/motion-dom/src/effects/style/transform.ts b/packages/motion-dom/src/effects/style/transform.ts index f8b506a033..ab60a4595f 100644 --- a/packages/motion-dom/src/effects/style/transform.ts +++ b/packages/motion-dom/src/effects/style/transform.ts @@ -26,7 +26,8 @@ export function buildTransform(state: MotionValueState) { if (typeof value === "number") { valueIsDefault = value === (key.startsWith("scale") ? 1 : 0) } else { - valueIsDefault = parseFloat(value) === 0 + const parsed = parseFloat(value) + valueIsDefault = key.startsWith("scale") ? parsed === 1 : parsed === 0 } if (!valueIsDefault) { diff --git a/packages/motion-dom/src/render/html/utils/build-transform.ts b/packages/motion-dom/src/render/html/utils/build-transform.ts index 17e52bdc2c..caceb10c31 100644 --- a/packages/motion-dom/src/render/html/utils/build-transform.ts +++ b/packages/motion-dom/src/render/html/utils/build-transform.ts @@ -43,7 +43,8 @@ export function buildTransform( if (typeof value === "number") { valueIsDefault = value === (key.startsWith("scale") ? 1 : 0) } else { - valueIsDefault = parseFloat(value) === 0 + const parsed = parseFloat(value) + valueIsDefault = key.startsWith("scale") ? parsed === 1 : parsed === 0 } if (!valueIsDefault || transformTemplate) { diff --git a/packages/motion-dom/src/value/types/maps/number.ts b/packages/motion-dom/src/value/types/maps/number.ts index be7c6ce678..a0d2682836 100644 --- a/packages/motion-dom/src/value/types/maps/number.ts +++ b/packages/motion-dom/src/value/types/maps/number.ts @@ -12,7 +12,6 @@ export const numberValueTypes: ValueTypeMap = { borderBottomWidth: px, borderLeftWidth: px, borderRadius: px, - radius: px, borderTopLeftRadius: px, borderTopRightRadius: px, borderBottomRightRadius: px, @@ -59,6 +58,9 @@ export const numberValueTypes: ValueTypeMap = { marginInlineStart: px, marginInlineEnd: px, + // Typography + fontSize: px, + // Misc backgroundPositionX: px, backgroundPositionY: px, diff --git a/packages/motion/package.json b/packages/motion/package.json index e5e753bcf7..4d77e446af 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.28.0", + "version": "12.28.2", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.28.0", + "framer-motion": "^12.28.2", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 12b92a3b74..d896350aca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,14 +7420,14 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.28.0, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.28.2, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: "@radix-ui/react-dialog": ^1.1.15 "@thednp/dommatrix": ^2.0.11 "@types/three": 0.137.0 - motion-dom: ^12.28.0 + motion-dom: ^12.28.2 motion-utils: ^12.27.2 three: 0.137.0 tslib: ^2.4.0 @@ -8192,9 +8192,9 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.28.0 - motion: ^12.28.0 - motion-dom: ^12.28.0 + framer-motion: ^12.28.2 + motion: ^12.28.2 + motion-dom: ^12.28.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0 @@ -10936,7 +10936,7 @@ __metadata: languageName: node linkType: hard -"motion-dom@^12.28.0, motion-dom@workspace:packages/motion-dom": +"motion-dom@^12.28.2, motion-dom@workspace:packages/motion-dom": version: 0.0.0-use.local resolution: "motion-dom@workspace:packages/motion-dom" dependencies: @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.28.0, motion@workspace:packages/motion": +"motion@^12.28.2, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.28.0 + framer-motion: ^12.28.2 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.28.0 + motion: ^12.28.2 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.28.0 + motion: ^12.28.2 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.28.0 + framer-motion: ^12.28.2 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0