Skip to content

Fix #3101: Normalise unitless 0 in clipPath WAAPI keyframes#3709

Open
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3101
Open

Fix #3101: Normalise unitless 0 in clipPath WAAPI keyframes#3709
mattgperry wants to merge 1 commit intomainfrom
worktree-fix-issue-3101

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • Chromium 134 intermittently fails to interpolate clip-path: inset(...) keyframes that mix unitless 0 and 0px lengths (the user's reproduction toggles between inset(0 0px 0 0px) and inset(0 120px 0 120px)), producing janky / glitching animations.
  • Before handing accelerated keyframes to element.animate(), replace any bare 0 length token (0 followed by whitespace, comma, or close-paren) with 0px so every length in the keyframe carries an explicit unit.
  • Scoped to clipPath for now since that is what's reported, but the helper takes a property-name allow list so additional shape-style properties can be added if more reports surface.

Cause

getComputedStyle and getAnimatableNone both preserve whichever unit notation the user supplies, so the keyframe pair Motion hands to WAAPI can keep the user's mixed 0/0px form. In Chromium 134 that mixed form interpolates incorrectly. Normalising to 0px produces a CSS-equivalent string that interpolates cleanly.

Notes for review

  • The bug does not reproduce in the Electron 80 used by Cypress, nor in the Chromium 138 bundled with Playwright (the Chromium fix appears to have shipped after 134). The Cypress test added here therefore serves as integration coverage rather than a hard regression gate; the unit tests around normaliseAcceleratedKeyframes are the real regression guard.
  • All 793 unit tests still pass; new and existing Cypress waapi/animate-style/animate-filter-blur tests pass on both React 18 and React 19.

Fixes #3101

Test plan

  • yarn test (793 passed, 7 skipped)
  • Cypress animate-clip-path.ts on React 18 and React 19
  • Cypress waapi.ts, animate-filter-blur.ts, animate-style.ts, waapi-interrupt-transform.ts regress

🤖 Generated with Claude Code

Chromium 134 intermittently produces janky `clip-path: inset(...)`
animations when keyframes mix unitless `0` and `0px` lengths (e.g.
`inset(0 0px 0 0px)` → `inset(0 120px 0 120px)`). Convert bare zero
length tokens to `0px` before handing keyframes to `element.animate()`
so every length carries the same explicit unit.

Fixes #3101

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 9, 2026

Greptile Summary

This PR fixes a Chromium 134 regression where clip-path: inset() keyframes mixing unitless 0 and 0px lengths would interpolate incorrectly via WAAPI. It adds a normaliseAcceleratedKeyframes utility that rewrites bare 0 length tokens to 0px before keyframes are handed to element.animate().

  • normalize-keyframes.ts: New utility scoped to clipPath; regex correctly handles inset(), polygon(), and other shape functions, and is guarded by a property allow-list for easy extension.
  • start-waapi-animation.ts: Minimal change — keyframes are now passed through normaliseAcceleratedKeyframes before reaching element.animate().
  • animate-clip-path.ts (Cypress): New integration test for [BUG] Animating clip-path in Chrome 134 seems broken #3101; the mid-animation assertion uses .should() rather than .then(), which the project style guide explicitly discourages for mid-animation checks (see inline comment).

Confidence Score: 4/5

Safe to merge; the fix is narrowly scoped to clipPath keyframes and the unit tests provide solid regression coverage.

The Cypress mid-animation check uses .should() — the project's own style guide explicitly warns this pattern masks the exact class of regression the test targets (animation skipping to the end). The unit test suite is the real regression gate (as noted in the PR description), but the Cypress test should still be corrected to match project conventions.

packages/framer-motion/cypress/integration/animate-clip-path.ts — the mid-animation .should() block should be converted to .then().

Important Files Changed

Filename Overview
packages/motion-dom/src/animation/waapi/utils/normalize-keyframes.ts New utility that normalises unitless 0 to 0px in WAAPI keyframes for clipPath; regex logic is correct but the $10px replacement string relies on a non-obvious ECMAScript fallback (see suggestion).
packages/motion-dom/src/animation/waapi/start-waapi-animation.ts Minimal integration change — wraps the keyframes array in normaliseAcceleratedKeyframes before passing to element.animate(); correctly no-ops for non-listed properties.
packages/framer-motion/cypress/integration/animate-clip-path.ts New Cypress integration test for #3101; mid-animation assertion uses .should() instead of the project-mandated .then(), which would mask the exact regression it targets.
packages/motion-dom/src/animation/waapi/utils/tests/normalize-keyframes.test.ts Thorough unit tests covering unrelated properties, unitless-zero normalisation, idempotency, percentages, polygon, single keyframe, and null keyframes.
dev/react/src/tests/animate-clip-path.tsx Simple React test page that toggles between mixed-unit inset() values to reproduce #3101; correctly mirrors the original bug report's reproduction.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["startWaapiAnimation(valueName, keyframes)"] --> B{"propsToNormalise.has(valueName)?"}
    B -- No --> D["Return keyframes as-is"]
    B -- Yes --> C["normaliseAcceleratedKeyframes()"]
    C --> E{"Array.isArray(keyframes)?"}
    E -- Yes --> F["map: string keyframes → addPxToZeros()"]
    E -- No --> G{"typeof keyframes === 'string'?"}
    G -- Yes --> H["addPxToZeros(keyframes)"]
    G -- No --> I["Return keyframe as-is (null/number)"]
    F --> J["element.animate(keyframeOptions, options)"]
    H --> J
    D --> J
    I --> J
Loading

Reviews (1): Last reviewed commit: "Normalise unitless 0 to 0px in clipPath ..." | Re-trigger Greptile

Comment on lines +10 to +27
.should(($el) => {
const el = $el[0] as HTMLElement
const clipPath = getComputedStyle(el).clipPath
// Extract numeric values from inset() string
const matches = clipPath.match(/-?\d+(?:\.\d+)?/g)
expect(matches, `clipPath was: ${clipPath}`).to.not.be.null
const numbers = matches!.map(Number)
// inset() resolves to 4 values [top, right, bottom, left].
// For our animation top/bottom stay 0, left/right go 0 → 120.
// At ~50% progress, left and right should be roughly 60 (give a wide tolerance).
expect(numbers.length).to.be.at.least(2)
// Find the largest value — it should be a meaningful intermediate
// value (not 0 and not the final 120).
const max = Math.max(...numbers)
expect(max, `expected mid-animation value, got ${clipPath}`)
.to.be.greaterThan(20)
.and.lessThan(100)
})
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 Mid-animation assertion uses .should() instead of .then()

The project's style guide (CLAUDE.md) explicitly warns: "Use .then(), not .should(), for mid-animation measurements. cy.should() retries assertions until they pass or timeout — so it will keep retrying until the animation completes, masking bugs." Here, .should() at the 500 ms checkpoint will keep retrying the assertion. If the Chromium regression reappears (animation never reaches an intermediate value and skips to the final), Cypress will simply time out with a confusing timeout error rather than a clear assertion failure — the mid-animation check can never definitively distinguish "stuck at 0 then jumped to 120" from "slowly reached 120."

Comment on lines +15 to +17
function addPxToZeros(value: string): string {
return value.replace(unitlessZeroLength, "$10px")
}
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 Non-obvious $10px replacement string relies on ECMAScript fallback behaviour

With only one capture group, the replacement "$10px" works because ECMA-262's GetSubstitution algorithm first tries to treat $10 as capture group 10, fails (only 1 group exists), then falls back to $1 (group 1) plus the literal character 0. The end result is correct — (preceding char) + "0px" — but this is not immediately legible to someone reading the code. Using a function replacement removes any ambiguity.

Suggested change
function addPxToZeros(value: string): string {
return value.replace(unitlessZeroLength, "$10px")
}
function addPxToZeros(value: string): string {
return value.replace(unitlessZeroLength, (_, prefix: string) => `${prefix}0px`)
}

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!

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.

[BUG] Animating clip-path in Chrome 134 seems broken

1 participant