Back to Motion

Plan issue-2654: Stop tokenising digits embedded in CSS identifiers (`matrix3d` parsed as `matrix` + `3` + `d`)

plans/issues/issue-2654.md

12.41.013.9 KB
Original Source

Plan issue-2654: Stop tokenising digits embedded in CSS identifiers (matrix3d parsed as matrix + 3 + d)

Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report — do not improvise. When done, update the status row for this plan in plans/issues/README.md (NOT plans/README.md).

Drift check (run first):

  1. gh api repos/motiondivision/motion/issues/2654 --jq .state → expect "open". If closed, mark this plan DONE in plans/issues/README.md and stop.
  2. git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/value/types/complex/index.ts packages/motion-dom/src/utils/mix/complex.ts packages/motion-dom/src/value/types/utils/float-regex.ts If any of these changed, compare the "Current state" excerpts against the live code; on a mismatch, STOP.

Status

  • Classification: FIX
  • Priority: P2
  • Effort: S
  • Risk: MED (regex change in the shared complex-value parser; mitigated by a verified case table and the full suite)
  • Depends on: none
  • Category: bug
  • Planned at: commit 42bfbe3ed, 2026-06-11
  • Issue: https://github.com/motiondivision/motion/issues/2654

Why this matters

analyseComplexValue tokenises the 3 inside matrix3d(...) (and translate3d, rotate3d, scale3d) as a standalone number. Two user-visible corruptions follow:

  1. complex.getAnimatableNone("matrix3d(1, …)") returns "matrix0d(0, …)" — this is exactly the matrix0d(0, 0, …) string in the issue's console log (it is generated by makeNoneKeyframesAnimatable when the origin keyframe is "none", i.e. no initial set).
  2. mixComplex then interpolates that bogus 0 toward 3, emitting per-frame values like matrix2.99999d(...) — invalid CSS that the browser rejects ("Invalid keyframe value for property transform").

The reporter's workaround (providing an explicit initial matrix3d) only hides corruption 1. The tokeniser bug was verified live at 42bfbe3ed during planning (see Current state).

Current state

  • packages/motion-dom/src/value/types/complex/index.ts:43-44 — the tokeniser regex; its final alternative -?(?:\d+(?:\.\d+)?|\.\d+) has no guard against digits inside identifiers:
ts
const complexRegex =
    /var\s*\(\s*--(?:[\w-]+\s*|[\w-]+\s*,(?:\s*[^)(\s]|\s*\((?:[^)(]|\([^)(]*\))*\))+\s*)\)|#[\da-f]{3,8}|(?:rgb|hsl)a?\((?:-?[\d.]+%?[,\s]+){2}-?[\d.]+%?\s*(?:[,/]\s*)?(?:\b\d+(?:\.\d+)?|\.\d+)?%?\)|-?(?:\d+(?:\.\d+)?|\.\d+)/giu
  • packages/motion-dom/src/value/types/complex/index.ts:60-76 — the tokenise callback. Every regex match becomes a token (color / var / number) and is replaced by SPLIT_TOKEN:
ts
    const tokenised = originalValue.replace(complexRegex, (parsedValue) => {
        if (color.test(parsedValue)) {
            indexes.color.push(i)
            types.push(COLOR_TOKEN)
            values.push(color.parse(parsedValue))
        } else if (parsedValue.startsWith(VAR_FUNCTION_TOKEN)) {
            indexes.var.push(i)
            types.push(VAR_TOKEN)
            values.push(parsedValue)
        } else {
            indexes.number.push(i)
            types.push(NUMBER_TOKEN)
            values.push(parseFloat(parsedValue))
        }
        ++i
        return SPLIT_TOKEN
    })
  • packages/motion-dom/src/value/types/utils/float-regex.ts:1export const floatRegex = /-?(?:\d+(?:\.\d+)?|\.\d+)/gu — same unguarded number pattern, but leave it unchanged (see Step 4 rationale).
  • packages/motion-dom/src/animation/keyframes/utils/make-none-animatable.ts:33-39 — converts "none" keyframes via getAnimatableNone(name, animatableTemplate), which routes transform to complex.getAnimatableNone (packages/motion-dom/src/value/types/utils/animatable-none.ts:8-15).
  • packages/motion-dom/src/utils/mix/complex.ts:100-135mixComplex re-analyses both keyframes and mixes token-by-token; it propagates the bogus 3/0 token into output frames.
  • Empirical facts verified at 42bfbe3ed (node, against source regex and built dist/cjs):
    • "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)".match(complexRegex) → 17 tokens starting ["3","1","0",…]; split begins ["matrix","d(",", ",…].
    • complex.getAnimatableNone("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)")"matrix0d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)".
    • "translate3d(10px, 20px, 0)" tokenises as ["3","10","20","0"] — same bug family.
  • git log --oneline --all --grep="matrix3d" → no prior fix for this; no lookbehind assertions exist anywhere in packages/*/src (grep -rn "(?<" packages/*/src → no source matches).
  • Existing tests to model after: packages/motion-dom/src/value/types/__tests__/index.test.ts (describe("complex value type")) and packages/motion-dom/src/utils/mix/__tests__/mix-complex.test.ts.

Commands you will need

PurposeCommand (from repo root)Expected on success
value-types testsnpx jest --config packages/motion-dom/jest.config.json --testPathPattern="value/types"pass/fail as stated per step
mix testsnpx jest --config packages/motion-dom/jest.config.json --testPathPattern="mix"pass/fail as stated per step
full motion-dom suitenpx jest --config packages/motion-dom/jest.config.jsonexit 0
build (required before framer-motion tests — they consume built motion-dom)yarn buildexit 0
framer-motion client testscd packages/framer-motion && yarn test-clientexit 0
Lintyarn lintexit 0

Scope

In scope (the only files you should modify):

  • packages/motion-dom/src/value/types/complex/index.ts
  • packages/motion-dom/src/value/types/__tests__/index.test.ts (add tests)
  • packages/motion-dom/src/utils/mix/__tests__/mix-complex.test.ts (add tests)

Out of scope:

  • packages/motion-dom/src/value/types/utils/float-regex.tsfloatRegex is used by complex.test() (match count only — matrix3d(...) contains 16 real numbers, so the count stays > 0 either way), complex/filter.ts:14 (first number of blur(10px)-style functions — no digit-embedded identifiers occur there) and color/utils.ts:26 (rgba/hsla component split). Changing it buys nothing for this bug and widens the regression surface.
  • make-none-animatable.ts, mix/complex.ts — both become correct automatically once the tokeniser stops emitting the bogus token.

Git workflow

  • Branch: fix/complex-parse-identifier-digits-2654
  • Commit 1: failing tests. Commit 2: fix. Do NOT push or open a PR unless the operator instructed it.

Steps

Step 1: Write the failing tests

In packages/motion-dom/src/value/types/__tests__/index.test.ts, inside describe("complex value type"), add (use a const const M3D = "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"):

  • complex.parse(M3D)[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] (16 numbers, no leading 3)
  • analyseComplexValue(M3D).split[0]"matrix3d(" (import analyseComplexValue from "../complex")
  • round-trip: complex.createTransformer(M3D)(complex.parse(M3D))M3D
  • complex.getAnimatableNone(M3D)"matrix3d(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)"
  • complex.parse("translate3d(10px, 20px, 0)")[10, 20, 0]
  • complex.parse("rotate3d(1, 1, 1, 45deg)")[1, 1, 1, 45]
  • complex.parse("scale3d(2, 2, 2)")[2, 2, 2]
  • complex.parse("perspective(500px) translate3d(10px, 10px, 0)")[500, 10, 10, 0]

In packages/motion-dom/src/utils/mix/__tests__/mix-complex.test.ts, add a test reproducing the issue's animation chain (import complex from "../../../value/types/complex"):

ts
test("mixComplex interpolates matrix3d from its animatable none", () => {
    const target = "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"
    expect(mixComplex(complex.getAnimatableNone(target), target)(0.5)).toBe(
        "matrix3d(0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5)"
    )
})

Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="value/types" and --testPathPattern="mix" → the new tests FAIL with outputs containing a leading 3 token / matrix0d / matrix1.5d. They must fail for that reason (the bug), not for an import error. At planning time the buggy outputs were confirmed against the built package, so a passing run here means drift — STOP.

Step 2: Fix the tokeniser (lookbehind-free)

In packages/motion-dom/src/value/types/complex/index.ts:

  1. Prepend an identifier alternative to complexRegex (it must be FIRST so it wins at positions where letters precede digits, and it requires a digit so plain words like rgb/px/inset are still skipped and the later rgb|hsl alternative still matches colors):
ts
const complexRegex =
    /[a-z]+\d[a-z\d]*|var\s*\(\s*--(?:…unchanged remainder…)/giu

i.e. add [a-z]+\d[a-z\d]*| at the very start; the i flag covers uppercase. Change nothing else in the regex.

  1. In the tokenise callback, return identifier matches unmodified so they stay part of the split text instead of becoming tokens. Add a branch BEFORE the number fallback (after the VAR_FUNCTION_TOKEN branch):
ts
        } else if (/^[a-z]/i.test(parsedValue)) {
            // Identifier with embedded digits (matrix3d, translate3d…) — not a number token
            return parsedValue
        } else {

The early return parsedValue must skip ++i and return SPLIT_TOKEN. (Note: color.test is checked first and still claims rgb(...)/hsl(...) matches, which also start with letters — keep the branch order exactly as above.)

This shape was validated during planning against: matrix3d, translate3d, rotate3d, scale3d, perspective(500px) translate3d(…), matrix(…), box-shadows with rgba, #fff3, linear-gradient(0.25turn, #3f87a6, #ebf8e1), rgba(161 0 246 / 0.5), var(--test-9) 60px, 10px 5.5% -0.5deg — all parse identically to today except the identifier digits are no longer tokenised.

Rejected alternative (do not use without approval): a lookbehind guard (?<![a-z]) on the number alternative also works, but lookbehind throws a SyntaxError at module-parse time on Safari < 16.4, crashing the entire library on import. The codebase currently contains zero lookbehinds. If you believe lookbehind is preferable, STOP and ask.

Verify: both Step 1 test commands → all new tests pass.

Step 3: Full regression run

Verify, in order:

  1. npx jest --config packages/motion-dom/jest.config.json → exit 0 (no existing complex/mix/ filter/mask/value-types test regresses).
  2. yarn build → exit 0.
  3. cd packages/framer-motion && yarn test-client → exit 0 (ignore only the pre-existing known failures if any are documented in MEMORY: SSR TextEncoder / use-velocity).
  4. yarn lint → exit 0.

The bug is pure string parsing and reproduces fully in JSDOM (no WAAPI involved in the corruption), so the unit tests above are the regression gate. If you add an E2E test, follow CLAUDE.md "Creating Cypress E2E tests": page dev/react/src/tests/animate-matrix3d.tsx with <motion.div animate={{ transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) scale(0.5)" }} transition={{ type: "tween", ease: "linear", duration: 10 }}> (no initial), spec packages/framer-motion/cypress/integration/animate-matrix3d.ts asserting at ~50% that getComputedStyle(el).transform parses as a valid matrix (not none). Must pass on React 18 AND React 19 (commands in CLAUDE.md). Skip if it cannot be made to fail pre-fix.

Step 5: Close the issue (gated)

ONLY after the row for this plan in plans/issues/README.md is marked APPROVED and the fix has landed on main: comment linking the fix commit/PR, then gh api -X PATCH repos/motiondivision/motion/issues/2654 -f state=closed -f state_reason=completed. Note: gh pr edit is broken on this repo — use gh api -X PATCH for any PR metadata edits.

Test plan

  • New failing-first unit tests listed in Step 1 (8 in index.test.ts, 1 in mix-complex.test.ts).
  • Entire existing motion-dom suite + framer-motion test-client stay green (Step 3).
  • Optional Cypress spec (Step 4) on React 18 + 19.

Done criteria

  • Step 1 tests existed and FAILED before the fix (for the bug, not a missing API)
  • All new tests pass; npx jest --config packages/motion-dom/jest.config.json exits 0
  • yarn build and cd packages/framer-motion && yarn test-client exit 0
  • No lookbehind introduced (grep -rn "(?<" packages/motion-dom/src → no new matches)
  • No files outside the in-scope list modified (git status)
  • plans/issues/README.md status row updated

STOP conditions

  • Step 1 tests PASS at the start (codebase drifted — the bug may already be fixed).
  • Any existing test in value/types, mix, filter, or mask fails after Step 2 and the failure isn't a trivial expectation referencing the old (buggy) token counts.
  • You find yourself wanting to modify float-regex.ts, mix/complex.ts, or make-none-animatable.ts — the fix belongs in the tokeniser only.
  • You want to use a lookbehind — ask first (Safari < 16.4 parse-time crash).

Maintenance notes

  • Scientific notation (6.1e-17 in getComputedStyle matrices) is mis-tokenised both before and after this fix (e-17's exponent is split off; post-fix e17-style runs are treated as identifiers). Pre-existing, unchanged here; a follow-up could add an (?:[eE]-?\d+)? suffix to the number alternative.
  • Reviewer should scrutinise: alternation order (identifier alternative must not shadow the rgb|hsl color alternative — it can't, because it requires a digit immediately after letters) and the early return parsedValue not incrementing the token index i.