plans/issues/issue-2365.md
style prop changes being swallowed by a stale memoized mergeExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/issues/README.md.Drift check (run first):
gh api repos/motiondivision/motion/issues/2365 --jq .state→ expectedopen. If closed, STOP.git log --oneline 42bfbe3ed..HEAD -- packages/framer-motion/src/render/svg/use-props.tsIf commits appear (PR #3749 touches theuseMemobody of this file), re-read the file and confirm the buggy merge excerpted below still exists at the bottom ofuseSVGProps. If it's gone, STOP and report.
worktree-style-effect) — its
diff to use-props.ts only swaps buildSVGAttrs for buildSVGProps
inside the memo and leaves the buggy merge untouched (verified against the
local worktree-style-effect branch). Expect a trivial textual conflict if
#3749 lands first; the fix below applies identically on top of it.42bfbe3ed, 2026-06-11Changing a plain (non-MotionValue) style prop value on any SVG motion
component (motion.svg, motion.rect, …) has no effect after the first
render: the first render's raw style values win forever. The reporter's
repro: motion.svg with style={{ background: clicked ? "blue" : "red" }}
stays red. The same pattern works on motion.div. Users are forced to wrap
every style value in a MotionValue as a workaround. CodeSandbox repro
(https://codesandbox.io/s/quirky-bhabha-qh8hrn) is Cloudflare-blocked at
planning time, but the issue text fully specifies the repro and the root
cause is confirmed statically below.
Root cause — packages/framer-motion/src/render/svg/use-props.ts:16-39:
const visualProps = useMemo(() => {
const state = createSvgRenderState()
buildSVGAttrs(state, visualState, isSVGTag(Component), props.transformTemplate, props.style)
return {
...state.attrs,
style: { ...state.style },
}
}, [visualState])
if (props.style) {
const rawStyles = {}
copyRawValuesOnly(rawStyles, props.style as any, props)
visualProps.style = { ...rawStyles, ...visualProps.style }
}
return visualProps
The bug is the last block, which runs every render but mutates the memoized object:
visualState is a stable reference (created once via useConstant in
packages/framer-motion/src/motion/utils/use-visual-state.ts:134-142),
so the useMemo never recomputes and visualProps is the same object on
every render.visualProps.style is replaced with
{ ...raw(red), ...builtStyles } — the raw values are now baked into the
memoized object.visualProps.style = { ...raw(blue), ...visualProps.style } — the spread
of the previous merged object (containing red) comes last and
overwrites the fresh raw value. Red wins on every subsequent render, and
stale keys accumulate.Contrast with the HTML path, which is correct because it builds a fresh
object every render — packages/framer-motion/src/render/html/use-props.ts:34-49:
function useStyle(props, visualState) {
const styleProp = props.style || {}
const style = {}
copyRawValuesOnly(style, styleProp as any, props)
Object.assign(style, useInitialMotionValues(props, visualState))
return style
}
Existing test exemplar for useSVGProps:
packages/framer-motion/src/render/svg/__tests__/use-props.test.ts (uses
renderHook from @testing-library/react).
| Purpose | Command (repo root) | Expected on success |
|---|---|---|
| Build | yarn build | exit 0 |
| Targeted Jest | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="svg/__tests__/use-props" | all pass |
| SVG-related Jest | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="svg" | all pass |
| Full client Jest | cd packages/framer-motion && yarn test-client | no new failures |
| Lint | yarn lint | exit 0 |
In scope (the only files you should modify):
packages/framer-motion/src/render/svg/use-props.tspackages/framer-motion/src/render/svg/__tests__/use-props.test.ts (extend)Out of scope:
packages/framer-motion/src/render/html/use-props.ts — already correct.useMemo recompute on props.style changes — built styles come
from visualState, not the style prop; the raw-value merge is the only
per-render concern. Do not add props.style to the dependency array.fix/issue-2365-svg-style-prop from main.Fix SVG style prop changes not applying across re-renders (#2365).Add to packages/framer-motion/src/render/svg/__tests__/use-props.test.ts:
test("re-applies changed raw style prop values across re-renders", () => {
const visualState = {} // stable reference, matching useConstant behaviour
const { result, rerender } = renderHook(
({ fill }: { fill: string }) =>
useSVGProps(
{ style: { fill } } as any,
visualState,
false,
"svg"
),
{ initialProps: { fill: "red" } }
)
expect((result.current.style as any).fill).toBe("red")
rerender({ fill: "blue" })
expect((result.current.style as any).fill).toBe("blue")
})
Also add a component-level test (same file or
packages/framer-motion/src/render/svg/__tests__/), mirroring the issue:
render <motion.svg style={{ background: "red" }} /> via the repo's
render helper (import { render } from "../../../jest.setup"), rerender
with background: "blue", assert
expect(container.firstChild).toHaveStyle("background: blue").
Verify: npx jest --config packages/framer-motion/jest.config.json --testPathPattern="svg/__tests__/use-props"
→ the new tests FAIL with the stale value ("red"). If they pass, STOP —
re-ground the root cause before changing anything.
In packages/framer-motion/src/render/svg/use-props.ts, replace the final
block (lines 33-39 at planning time) so each render returns a fresh
top-level object and the memoized visualProps is never written to:
if (!props.style) return visualProps
const rawStyles = {}
copyRawValuesOnly(rawStyles, props.style as any, props)
return {
...visualProps,
style: { ...rawStyles, ...visualProps.style },
}
Notes:
visualProps.style (built, animated values) must still win over raw
values — keep it last in the spread.Verify: Step 1's Jest command → all tests pass, including the two new ones.
Verify:
npx jest --config packages/framer-motion/jest.config.json --testPathPattern="svg" → all pass.cd packages/framer-motion && yarn test-client → no new failures
(pre-existing SSR TextEncoder / use-velocity failures are known).yarn lint → exit 0.Cypress is not required: this is pure React-render/object-identity logic,
fully observable in JSDOM (no WAAPI, no layout). The existing SVG Cypress
specs (svg.ts, svg-style-on-mount.ts) guard the mount-time behaviour and
must stay green in CI.
motion.svg style prop change reflected in DOM.packages/framer-motion/src/render/svg/__tests__/use-props.test.ts.yarn build exits 0use-props tests still passgrep -n "visualProps.style =" packages/framer-motion/src/render/svg/use-props.ts returns no matches (no more mutation)git status)plans/issues/README.md status row updateduse-props.ts (e.g. #3749
follow-ups landed) and the conflict is more than textual.use-props.test.ts assertions fail after Step 2 — particularly
the toStrictEqual shapes; that would mean the fresh-object return changed
observable prop identity in a way tests pin down. Report rather than
loosening assertions.useHTMLProps (fresh object per render) and
useSVGProps (memoized + per-render merge) is what allowed this bug;
if #3749's effects pipeline later unifies SVG prop building, this test is
the regression gate for raw-style updates.plans/issues/README.md row
for this plan is marked APPROVED.