Skip to content

Replace VisualElement value management and renderer with effects#3749

Open
mattgperry wants to merge 4 commits into
mainfrom
worktree-style-effect
Open

Replace VisualElement value management and renderer with effects#3749
mattgperry wants to merge 4 commits into
mainfrom
worktree-style-effect

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Summary

Unifies Motion's two rendering pipelines. VisualElement (used by <motion.div /> and animate()) now manages and renders motion values through the effects system (addStyleValue/addSVGValue/MotionValueState) instead of its own build()/renderState/renderInstance() pipeline. Anyone mixing motion components with styleEffect/svgEffect no longer ships both implementations, and non-layout elements gain granular rendering: animating a subset of values only writes that subset per frame, instead of rebuilding every style.

Stage 1 — slot/contributor model (MotionValueState)

  • Composed outputs (transform, transformOrigin, stroke-dasharray) are built from ordered contributor chains via state.contribute(name, index, builder) / state.build(name). Contributors can wrap or override previous output (base builder → projection override; transformTemplate handled inside base builders). Signals-shaped, but explicit and ordered — no auto-tracking runtime, batching/glitch-freedom comes from the existing frameloop.
  • state.latest now stores raw values; value-type coercion happens at write sites, matching latestValues semantics.
  • buildTransform/buildTransformOrigin are now single canonical builders shared by every consumer; pathRotation composition is down from 3 sites to 2 (the remaining one in buildProjectionTransform is genuinely different maths). The public 3-arg buildTransform signature is preserved via a thin template wrapper.

Stage 2 — removal semantics

  • state.remove(name) unsubscribes, deletes from latest, and re-renders the owning slot — the contract VisualElement.removeValue requires. The unsubscribe returned from set() deliberately keeps its existing semantics for standalone effects.

Stage 3 — the swap

  • bindValueToState()/renderValues() per renderer replace build()/renderInstance(). Full synchronous renders flush everything in latest, including values set via setStaticValue during measurement flows.
  • Deleted: buildHTMLStyles, buildSVGAttrs, buildSVGPath, renderHTML, renderSVG, render-side build-transform, and all renderState usage.
  • React/SSR initial styles come from new shared buildStyles (HTML) and buildSVGProps (SVG) builders. SSR output is byte-identical to before (vars-first property ordering preserved).
  • Projection writes corrected CSS vars via style.setProperty instead of renderState.vars.

Rendering semantics

  • Non-layout elements: granular — each value/slot renders independently on change.
  • Projection-driven elements (layout, layoutId, or drag's alwaysMeasureLayout): values bind as latest-tracking only and schedule full renders per change — exactly the old pipeline's behavior. Projection output must compose after styles in deterministic order; the renderScheduledAt dedupe means a granular render could otherwise land after the projection render within a frame (caught by the defer-handoff-layout appear fixtures). Note: a projection node exists for every element once layout features load, so this is gated on projection options, not existence. Elements re-bind if they become projection-driven post-mount (lazy-loaded features).
  • No imperative render at bind: React owns initial styles; bind-time writes broke deferred appear-handoff (inline transform fighting the WAAPI appear animation). Standalone styleEffect/svgEffect render initial values via an explicit state.scheduleRender() in createEffect.

Behavior changes

  • SVG values such as fill/opacity/width now render as styles rather than attributes where the browser supports them (key in element.style). Equal-or-faster (presentation attributes pass through the CSS cascade anyway, plus attribute-mutation overhead) and inline styles win specificity deterministically. Initial React render still emits attributes via buildSVGProps; styles take priority once animating.
  • attr-prefixed SVG values (attrX) are now tracked under their prefixed key, fixing a latent collision with same-named transform values in svgEffect.
  • Each bound value has two change subscriptions (state renderer + VisualElement event/projection notifier); the no-accumulation-across-rerenders guarantee still holds and is tested.

⚠️ API removals (Framer compat check before release)

  • motion-dom exports removed: buildHTMLStyles, buildSVGAttrs, buildSVGPath, renderHTML, renderSVG (replaced by buildStyles, buildSVGProps).
  • VisualElement: build(), renderInstance(), triggerBuild(), removeValueFromRenderState() removed; state, bindValueToState(), renderValues() added.

Bundle size (gzipped, vs main)

Bundle Δ
size-rollup-m −0.09 kB
size-rollup-motion +0.92 kB
dom-animation / dom-max / animate +0.6–0.7 kB
style-effect (standalone) +0.26 kB

The adds are the slot mechanism plus SVG temporarily carrying both granular and full renderers — best follow-up trim is merging those. The structural win is the single pipeline (no double-ship with effects) and granular rendering.

Test plan

  • motion-dom jest: 494 passing
  • framer-motion client jest: 787 passing
  • framer-motion SSR jest: 49 passing
  • Cypress React 18: all 87 specs passing
  • Cypress React 19: all 87 specs passing
  • Cypress HTML suite: 159/159 (projection 108, optimized-appear 27, animate-layout 24)
  • New regression tests: slot model (10), buildStyles/buildSVGProps (ported + extended), SVG transformBox granular path

🤖 Generated with Claude Code

mattgperry and others added 3 commits June 10, 2026 10:39
Slots (transform, transformOrigin, stroke-dasharray) are now composed
from ordered contributor chains via state.contribute()/state.build(),
giving projection, transformTemplate and pathRotation a single seam to
compose rendered output instead of competing render sites.

- MotionValueState.latest now stores raw values; value-type coercion
  moves to render/build sites so latest matches VisualElement's
  latestValues semantics
- Canonical buildTransform now lives in effects/style/transform,
  shared by both the effects and VisualElement render pipelines;
  the render-side copy is reduced to a transformTemplate wrapper
  that preserves the public 3-arg signature
- buildTransformOrigin extracted as a shared builder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remove() unsubscribes a value, deletes it from latest and re-renders
any slot it contributed to - the semantics VisualElement.removeValue
needs. The unsubscribe returned from set() deliberately keeps its
existing behavior (latest persists for shared slots).

Pins the transformTemplate pattern: the framework layer swaps the
transform slot's base contributor for one that builds via the
template-aware buildTransform wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VisualElement now binds motion values through the effects system
(addStyleValue/addSVGValue) on a shared MotionValueState whose latest
object is latestValues. The build()/renderState/renderInstance pipeline
is removed, along with buildHTMLStyles, buildSVGAttrs, buildSVGPath,
renderHTML and renderSVG.

- Granular rendering: non-layout elements render only changed
  values/slots per frame instead of rebuilding every style
- Projection-driven elements (layout/layoutId/drag) bind values as
  latest-tracking only and schedule full renders per change - projection
  output must compose after styles in a deterministic order, and the
  renderScheduledAt dedupe means granular renders could otherwise land
  after the projection render within a frame. Re-binds if a projection
  node arrives post-mount (lazy features)
- No imperative render at bind: React owns initial styles; standalone
  styleEffect/svgEffect render initial values via state.scheduleRender().
  This keeps deferred appear-handoff animations in control of transform
- React/SSR initial styles built by shared buildStyles/buildSVGProps,
  replacing the duplicated render-side builders
- renderValues()/bindValueToState() replace build()/renderInstance()
  per renderer; full sync renders flush every value in latest, including
  those set via setStaticValue during measurement
- transformTemplate registers a template-aware base contributor on the
  transform slot
- Projection writes corrected CSS vars via style.setProperty instead of
  renderState.vars
- SVG: fill/opacity etc now render as styles rather than attributes
  (faster, attribute writes pass through the CSS cascade anyway, and
  inline styles win specificity deterministically). attr-prefixed state
  keys no longer collide with same-named transform values

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR unifies Motion's two rendering pipelines by replacing VisualElement's build()/renderInstance()/renderState pipeline with the effects-based MotionValueState slot system, enabling granular per-value rendering for non-layout elements while keeping full-render semantics for projection-driven ones.

  • MotionValueState extended with an ordered slot/contributor model (contribute/build) that manages composed outputs like transform and stroke-dasharray; buildTransform/buildTransformOrigin are now single canonical implementations shared across all consumers.
  • HTML pipeline replaced: HTMLVisualElement delegates to addStyleValue/renderStyles; buildHTMLStyles, renderHTML, and HTMLRenderState are removed; buildStyles/buildSVGProps handle React/SSR initial render.
  • SVG behavior change: fill, opacity, and other presentation attributes that exist in element.style now render as inline styles rather than attributes (inline styles win specificity; the Cypress test is updated to match).

Confidence Score: 3/5

Two functional regressions should be resolved before merging: SVG layout animations silently stop working, and a subscription leak occurs when layout features are lazy-loaded onto existing elements.

The HTML rendering path and the MotionValueState slot model are well-constructed and thoroughly tested. Two issues exist in edge paths: SVGVisualElement.renderValues drops the projection parameter that renderSVG previously forwarded to applyProjectionStyles, which breaks layout/layoutId on SVG elements; and the lazy-load re-bind loop overwrites valueSubscriptions entries without calling the old cleanup, leaving stale value.on change closures that fire on every subsequent value change.

SVGVisualElement.ts (missing projection call) and VisualElement.ts (re-bind loop around line 770)

Important Files Changed

Filename Overview
packages/motion-dom/src/effects/MotionValueState.ts New slot/contributor model with ordered build chains; remove() correctly delegates to onRemove() which calls values.delete(name); well-tested
packages/motion-dom/src/render/VisualElement.ts Core bind/render pipeline refactored; the lazy-load re-bind path leaks old value.on(change) subscriptions because valueSubscriptions.get(key)?.() is not called before re-binding
packages/motion-dom/src/render/svg/SVGVisualElement.ts Old renderSVG forwarded projection to applyProjectionStyles; new renderValues ignores the projection parameter, breaking layout animations on SVG elements
packages/motion-dom/src/render/html/HTMLVisualElement.ts Removed build/renderInstance; new bindValueToState delegates to addStyleValue, renderValues calls renderStyles then applyProjectionStyles — correct
packages/motion-dom/src/render/dom/DOMVisualElement.ts Adds transformTemplate slot wiring; contributor unsubscribe is discarded but graceful since this.props.transformTemplate is read at render time and state is recreated on remount
packages/motion-dom/src/effects/style/index.ts Refactors transform/origin slot creation into addTransformSlot; minor comment inversion (SVG vs HTML) flagged
packages/motion-dom/src/effects/svg/render.ts New renderSVGValues correctly routes transform/origin values as styles and attributes; fill-box transformBox default matches old behavior
packages/motion-dom/src/effects/style/transform.ts Canonical buildTransform/buildTransformOrigin extracted; transformValues accumulation for transformTemplate matches the pre-existing pattern
packages/motion-dom/src/effects/svg/build.ts New buildSVGProps replaces buildSVGAttrs; pathSpacing default changed from 1-pathLength to 1, semantically equivalent for normalized paths
packages/motion-dom/src/projection/node/create-projection-node.ts Removes HTMLVisualElement cast; CSS variables now written via setProperty directly on targetStyle — correct and less fragile

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Value Change] --> B{isProjectionDriven?}
    B -- No --> C[bindValueToState per renderer]
    B -- Yes --> D[state.set track-only]
    C --> E[granular render per-value onChange]
    D --> F[scheduleRender full render on change]
    F --> G[renderValues]
    E --> H[frame.render per slot]
    G --> I{renderer type}
    I -- HTML --> J[renderStyles + applyProjectionStyles]
    I -- SVG --> K[renderSVGValues projection ignored]
    I -- Object --> L[Object.assign state.latest]
    H --> M{slot type}
    M -- transform --> N[state.build transform]
    M -- transformOrigin --> O[buildTransformOrigin]
    M -- plain style --> P[element.style key = value]
Loading

Reviews (1): Last reviewed commit: "Replace VisualElement value management a..." | Re-trigger Greptile

Comment on lines +57 to 67
return addSVGValue(instance, state, key, value)
}

mount(instance: SVGElement) {
this.isSVGTag = isSVGTag(instance.tagName)
super.mount(instance)
renderValues(
instance: SVGElement,
state: MotionValueState,
_styleProp?: MotionStyle,
_projection?: any
) {
renderSVGValues(instance, state)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing projection?.applyProjectionStyles for SVG layout animations

The old renderSVG called renderHTML(..., projection) which forwarded the projection node to applyProjectionStyles. The new renderValues ignores _projection entirely, so SVG elements with layout or layoutId props will never have their projection transforms applied. Any layout animation on <motion.circle layout /> or similar SVG shapes will fail silently — the element won't animate into position.

Comment on lines 764 to +779
this.scheduleRender()
}

/**
* If this element has become projection-driven since values were
* bound (e.g. lazy-loaded layout features, or layout props toggled
* on), re-bind them so rendering defers to full renders that
* compose projection output.
*/
if (
this.current &&
!this.hasProjectionBindings &&
this.isProjectionDriven()
) {
this.hasProjectionBindings = true
this.values.forEach((value, key) =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Leaked removeOnChange subscriptions during lazy-load re-bind

When an element transitions from non-projection-driven to projection-driven (e.g., lazy-loading dom-animation or dynamically adding a layout prop), bindToMotionValue is called for every existing value. Inside, state.set() correctly cleans up the old state subscription, but the value.on("change", ...) subscription registered in the previous bindToMotionValue call is never removed — its cleanup is stored in valueSubscriptions but that map entry is silently overwritten rather than called before re-binding. After this transition, each motion value has two active "change" listeners (scheduleRender fires twice per change), and the old closure over this is permanently retained by the MotionValue, preventing GC until the MotionValue itself is destroyed. The fix is to call this.valueSubscriptions.get(key)?.() and delete the key before re-binding.

Comment on lines +44 to +46
// If this is an SVG element, we need to set the transform-box to fill-box
// to normalise the transform relative to the element's bounding box
if (!isHTML && !state.get("transformBox")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Comment says "HTML element" but the guard !isHTML targets SVG elements — the condition and its comment are inverted.

Suggested change
// If this is an SVG element, we need to set the transform-box to fill-box
// to normalise the transform relative to the element's bounding box
if (!isHTML && !state.get("transformBox")) {
// If this is an SVG element, we need to set transform-box to fill-box
// to normalise the transform relative to the element's bounding box
if (!isHTML && !state.get("transformBox")) {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

…functions

The SVG pipeline shipped two routing implementations: addSVGValue (granular
bind-time closures) and renderSVGValues (full renders for projection-driven
elements and measurement flows), with a third partial copy in the transform
slot closures. Routing, attribute writing, transform/fill-box/origin
defaults and dasharray building are now single implementations
(renderSVGValue, renderAttrValue, renderTransform, renderTransformOrigin,
buildDasharray, renderPathOffset) dispatched from both paths.

Merging surfaced two latent routing bugs in the granular path, both fixed
with regression tests:

- pathRotation (a transform prop) was captured by the path* check and wired
  into stroke-dasharray, setting pathLength="1" and dropping its rotation
  from the transform. Transform/origin checks now run before path checks in
  both renderers.
- CSS variables on SVG elements fell through to setAttribute("--foo"),
  an invalid XML attribute name. They now route to style.setProperty,
  matching the old pipeline and the initial-render builders.

SVG-ness is passed explicitly through addSVGValue/renderSVGValues/
addTransformSlot rather than duck-typed per render: renderer choice is by
tag, and an SVG tag rendered outside an <svg> root lives in the HTML
namespace where instance checks misreport it. The bind-time transformBox
MotionValue is gone; fill-box now renders with the transform slot, gated on
a user-provided transformBox in latest, making it order-independent.

Bundle size (gzipped): motion -158 B, animate -155 B, dom-animation -166 B,
dom-max -155 B (incl. assets chunks); m unchanged; standalone style-effect
+39 B from the shared fallback path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant