plans/issues/issue-3735.md
window accesses in motion-dom so animations work in non-browser JS runtimesExecutor 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.Drift check (run first):
gh api repos/motiondivision/motion/issues/3735 --jq .state→ expect"open". If closed, mark this plan REJECTED (fixed independently) and stop.git log --oneline 42bfbe3ed..HEAD -- packages/motion-dom/src— if any commit touches the files in Scope, re-verify every excerpt in "Current state" against the live code; on a mismatch, STOP.
42bfbe3ed, 2026-06-11animateTarget in motion-dom reads window.MotionHandoffAnimation with no
typeof window guard, so any runtime where window is undefined (or lexically
shadowed, as in Lynx's web runtime which wraps bundles in
(function(){ const window = void 0; ... })()) throws
TypeError: Cannot read properties of undefined (reading 'MotionHandoffAnimation')
the moment an animation starts. A dozen other motion-dom files have the same
unguarded pattern. The reporter (Huxpro, Lynx framework) has a ready-made fix on
a fork — the repo blocks external PRs, so we land it ourselves.
Verified against the working tree at 42bfbe3ed:
packages/motion-dom/src/animation/interfaces/visual-element-target.ts:117 — the reported crash:
if (window.MotionHandoffAnimation) {
window.MotionHandoffAnimation(appearId, key, frame))packages/motion-dom/src/render/utils/reduced-motion/index.ts:3 — the existing pattern to generalise:
const isBrowser = typeof window !== "undefined"
packages/motion-dom/src/render/VisualElement.ts:592-595 — already guarded
(typeof window !== "undefined" && (window as any).MotionCheckAppearSync).
The issue's affected-files list includes it; it needs NO change. Do not touch.packages/motion-dom/src/projection/node/create-projection-node.ts:124,126 (window.MotionHasOptimisedAnimation!, window.MotionCancelOptimisedAnimation!), :468,472 (window.innerWidth), :677 (window.MotionCancelOptimisedAnimation &&)packages/motion-dom/src/projection/node/HTMLProjectionNode.ts:14-21 (documentNode.mount(window) inside defaultParent), :27 (window.getComputedStyle in checkIsScrollRoot)packages/motion-dom/src/render/dom/style-computed.ts:7packages/motion-dom/src/render/html/HTMLVisualElement.ts:21packages/motion-dom/src/animation/keyframes/DOMKeyframesResolver.ts:154,159,186packages/motion-dom/src/animation/keyframes/KeyframesResolver.ts:67 (window.scrollTo)packages/motion-dom/src/animation/utils/css-variables-conversion.ts:44packages/motion-dom/src/gestures/hover.ts:69,73,86,91packages/motion-dom/src/gestures/press/index.ts:72,73,102,103packages/motion-dom/src/resize/handle-window.ts:11,14,21,36packages/motion-dom/src/utils/supports/scroll-timeline.ts:28,33 (window.ScrollTimeline/window.ViewTimeline inside memoSupports callbacks)Huxpro/motion:fix/add-typeof-window-guards (1 commit ahead of main, touches
12 source files + new utils/is-browser.ts). View it:
curl -sL "https://github.com/Huxpro/motion/compare/motiondivision:main...Huxpro:fix/add-typeof-window-guards.diff".
Use it as a reference, but prefer the shared isBrowser import at every site
(the fork mixes inline typeof window !== "undefined" and isBrowser;
a shared const minifies smaller — CLAUDE.md prioritises output bytes).| Purpose | Command | Expected on success |
|---|---|---|
| Build all packages (repo root) | yarn build | exit 0 |
| motion-dom unit tests | npx jest --config packages/motion-dom/jest.config.json | all pass |
| New test only | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="visual-element-target" | pass after fix |
| framer-motion client tests | cd packages/framer-motion && yarn test-client | matches pre-change baseline |
| framer-motion SSR tests | cd packages/framer-motion && yarn test-server | matches pre-change baseline (known pre-existing TextEncoder failures may exist — capture baseline BEFORE editing) |
| Lint | yarn lint | exit 0 |
In scope (the only files you may modify/create):
packages/motion-dom/src/utils/is-browser.ts (create)packages/motion-dom/src/render/utils/reduced-motion/index.ts (use shared const)packages/motion-dom/src/animation/interfaces/visual-element-target.tspackages/motion-dom/src/animation/interfaces/__tests__/visual-element-target.test.ts (create)packages/motion-dom/src/projection/node/create-projection-node.tspackages/motion-dom/src/projection/node/HTMLProjectionNode.tspackages/motion-dom/src/render/dom/style-computed.tspackages/motion-dom/src/render/html/HTMLVisualElement.tspackages/motion-dom/src/animation/keyframes/DOMKeyframesResolver.tspackages/motion-dom/src/animation/keyframes/KeyframesResolver.tspackages/motion-dom/src/animation/utils/css-variables-conversion.tspackages/motion-dom/src/gestures/hover.tspackages/motion-dom/src/gestures/press/index.tspackages/motion-dom/src/resize/handle-window.tspackages/motion-dom/src/utils/supports/scroll-timeline.tsOut of scope:
packages/motion-dom/src/render/VisualElement.ts — already guarded (line 592).document. accesses anywhere — the issue is strictly about window.From repo root run cd packages/framer-motion && yarn test-server and
yarn test-client; save the pass/fail summary. Pre-existing failures are not
yours to fix — you only must not add new ones.
Create packages/motion-dom/src/animation/interfaces/__tests__/visual-element-target.test.ts:
/**
* @jest-environment node
*/
import { animateTarget } from "../visual-element-target"
import type { VisualElement } from "../../../render/VisualElement"
test("animateTarget does not throw when window is undefined (#3735)", () => {
expect(typeof window).toBe("undefined")
const start = jest.fn()
const fakeValue = {
get: () => 0,
isAnimating: () => false,
start,
animation: undefined,
}
const visualElement = {
getDefaultTransition: () => undefined,
getValue: (key: string) =>
key === "willChange" ? undefined : fakeValue,
addValue: () => {},
latestValues: {},
shouldReduceMotion: false,
animationState: undefined,
} as unknown as VisualElement
expect(() => animateTarget(visualElement, { opacity: 1 })).not.toThrow()
expect(start).toHaveBeenCalledTimes(1)
})
The @jest-environment node docblock makes window genuinely undefined,
reproducing the runtime class the issue describes. The stub is sufficient:
animateTarget reaches window.MotionHandoffAnimation (line 117) because
fakeValue.get() === 0 !== 1 skips the same-value early-out, and
animateMotionValue is curried so value.start (mocked) never executes DOM code.
Verify (must FAIL): npx jest --config packages/motion-dom/jest.config.json --testPathPattern="visual-element-target"
→ fails with ReferenceError: window is not defined (node's flavour of the
reported TypeError). If it fails for any other reason (e.g. a module-level
import crashes in node env), STOP and report.
isBrowser utilCreate packages/motion-dom/src/utils/is-browser.ts:
export const isBrowser = typeof window !== "undefined"
In render/utils/reduced-motion/index.ts, delete the local
const isBrowser = typeof window !== "undefined" (line 3) and import it from
../../../utils/is-browser instead.
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="reduced-motion" → no new failures (or no matching tests; then run full motion-dom suite).
Import isBrowser (relative path per file) and apply. Guard semantics: in a
non-browser runtime each operation becomes a safe no-op; in browsers behaviour
is byte-for-byte identical.
| File | Change |
|---|---|
visual-element-target.ts:117 | if (isBrowser && window.MotionHandoffAnimation) { |
create-projection-node.ts:124 | if (isBrowser && window.MotionHasOptimisedAnimation!(appearId, "transform")) { |
create-projection-node.ts:468 | frame.read(() => { if (isBrowser) innerWidth = window.innerWidth }) |
create-projection-node.ts:472 | first line of the attachResizeListener callback: if (!isBrowser) return |
create-projection-node.ts:677 | condition becomes isBrowser && window.MotionCancelOptimisedAnimation && !this.hasCheckedOptimisedAppear |
HTMLProjectionNode.ts:14 | if (!rootProjectionNode.current && isBrowser) { (so defaultParent returns undefined off-browser) |
HTMLProjectionNode.ts:27 | checkIsScrollRoot: (instance) => Boolean(isBrowser && window.getComputedStyle(instance).position === "fixed") |
style-computed.ts:7 | insert if (!isBrowser) return "" before the window.getComputedStyle call |
HTMLVisualElement.ts:21 | return isBrowser ? window.getComputedStyle(element) : ({} as CSSStyleDeclaration) |
DOMKeyframesResolver.ts:154 | if (name === "height" && isBrowser) { |
DOMKeyframesResolver.ts (before line ~159 this.measuredOrigin = ...) | insert if (!isBrowser) return |
DOMKeyframesResolver.ts:186 area (measureEndState) | extend early return: `if (!element |
KeyframesResolver.ts:67 | if (resolver.suspendedScrollY !== undefined && isBrowser) { |
css-variables-conversion.ts:44 | insert if (!isBrowser) return fallback before the window.getComputedStyle line |
hover.ts:69-91 | wrap the two window.removeEventListener calls and the two window.addEventListener calls in if (isBrowser) { ... } blocks |
press/index.ts:72-73,102-103 | same if (isBrowser) wrapping |
handle-window.ts | first line of createWindowResizeHandler(): if (!isBrowser) return (the cleanup's removeEventListener at line 36 only runs when the handler was created, i.e. in-browser — no extra guard needed) |
scroll-timeline.ts:28,33 | () => isBrowser && window.ScrollTimeline !== undefined and () => isBrowser && window.ViewTimeline !== undefined |
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="visual-element-target" → PASSES.
yarn build (repo root) → exit 0.npx jest --config packages/motion-dom/jest.config.json → all pass.cd packages/framer-motion && yarn test-client && yarn test-server → no new failures vs. Step 1 baseline.yarn lint → exit 0.Branch e.g. fix/3735-window-guards. PR body: link issue #3735, credit the
reporter's fork (Thanks @Huxpro — based on Huxpro/motion:fix/add-typeof-window-guards),
note that VisualElement.ts was already guarded. gh pr edit is broken on
this repo — if body edits are needed use
gh api -X PATCH repos/motiondivision/motion/pulls/<n> -f body=....
visual-element-target.test.ts (node env) — the regression gate for the
reported crash; written first, observed failing (Step 2), passing after Step 4.isBrowser is true and all
guards are transparent.grep -rn "window\." packages/motion-dom/src --include="*.ts" | grep -v __tests__ | grep -v "typeof window" | grep -v "isBrowser" shows no unguarded executable window. access outside guarded blocks (manually confirm remaining hits are inside if (isBrowser) scopes, type declarations, or comments)yarn build and yarn lint exit 0; motion-dom suite green; framer-motion client/SSR suites match baselinegit status)plans/issues/README.md status row updatedwindow is not defined (means the module graph itself isn't node-safe and the fix is bigger than planned).defaultParent returning undefined in HTMLProjectionNode.ts — check ProjectionNodeConfig type before improvising a cast.window in motion-dom should import isBrowser — a
lint rule (no-restricted-globals) would prevent regressions; deferred.DOMKeyframesResolver early-returns — they change
measurement behaviour only when isBrowser is false (measurement is
meaningless there anyway).