plans/issues/issue-2654.md
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(NOTplans/README.md).Drift check (run first):
gh api repos/motiondivision/motion/issues/2654 --jq .state→ expect"open". If closed, mark this plan DONE inplans/issues/README.mdand stop.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.tsIf any of these changed, compare the "Current state" excerpts against the live code; on a mismatch, STOP.
42bfbe3ed, 2026-06-11analyseComplexValue tokenises the 3 inside matrix3d(...) (and translate3d,
rotate3d, scale3d) as a standalone number. Two user-visible corruptions follow:
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).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).
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: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: 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:1 —
export 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-135 — mixComplex re-analyses both
keyframes and mixes token-by-token; it propagates the bogus 3/0 token into output frames.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).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.| Purpose | Command (from repo root) | Expected on success |
|---|---|---|
| value-types tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="value/types" | pass/fail as stated per step |
| mix tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="mix" | pass/fail as stated per step |
| full motion-dom suite | npx jest --config packages/motion-dom/jest.config.json | exit 0 |
| build (required before framer-motion tests — they consume built motion-dom) | yarn build | exit 0 |
| framer-motion client tests | cd packages/framer-motion && yarn test-client | exit 0 |
| Lint | yarn lint | exit 0 |
In scope (the only files you should modify):
packages/motion-dom/src/value/types/complex/index.tspackages/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.ts — floatRegex 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.fix/complex-parse-identifier-digits-2654In 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")complex.createTransformer(M3D)(complex.parse(M3D)) → M3Dcomplex.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"):
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.
In packages/motion-dom/src/value/types/complex/index.ts:
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):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.
split text instead of becoming tokens. Add a branch BEFORE the number fallback
(after the VAR_FUNCTION_TOKEN branch): } 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.
Verify, in order:
npx jest --config packages/motion-dom/jest.config.json → exit 0 (no existing complex/mix/
filter/mask/value-types test regresses).yarn build → exit 0.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).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.
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.
index.test.ts, 1 in mix-complex.test.ts).test-client stay green (Step 3).npx jest --config packages/motion-dom/jest.config.json exits 0yarn build and cd packages/framer-motion && yarn test-client exit 0grep -rn "(?<" packages/motion-dom/src → no new matches)git status)plans/issues/README.md status row updatedvalue/types, mix, filter, or mask fails after Step 2 and the
failure isn't a trivial expectation referencing the old (buggy) token counts.float-regex.ts, mix/complex.ts, or
make-none-animatable.ts — the fix belongs in the tokeniser only.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.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.