Skip to content

Guard window access for non-browser runtimes (#3735)#3751

Open
mattgperry wants to merge 1 commit into
mainfrom
worktree-fix-issue-3735
Open

Guard window access for non-browser runtimes (#3735)#3751
mattgperry wants to merge 1 commit into
mainfrom
worktree-fix-issue-3735

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

Bug

In non-browser JS runtimes — or when window is shadowed to undefined by an IIFE wrapper (e.g. Lynx's web runtime, which wraps main-thread scripts in (function(){ const window = void 0; ... })()) — motion-dom threw:

TypeError: Cannot read properties of undefined (reading 'MotionHandoffAnimation')

The immediate crash was in animateTarget (animation/interfaces/visual-element-target.ts), which read window.MotionHandoffAnimation without a typeof window guard. The same unguarded window access existed across a number of other modules, so fixing only animateTarget would just move the crash one step downstream.

Cause

Bare window.<prop> accesses assume a browser global. When window is undefined (or an undeclared binding) the property read throws. Other modules already do this correctly — e.g. render/utils/reduced-motion/index.ts uses typeof window !== "undefined".

Fix

Add a typeof window !== "undefined" guard before each unguarded window access. typeof never throws, even on an undefined binding. Crucially, every guard evaluates to true in a browser, so existing behaviour is unchanged — these are no-ops outside non-browser runtimes.

Guarded sites:

  • animation/interfaces/visual-element-target.tswindow.MotionHandoffAnimation (the reported crash)
  • projection/node/create-projection-node.tsMotionHasOptimisedAnimation, MotionCancelOptimisedAnimation, innerWidth
  • projection/node/HTMLProjectionNode.tsmount(window), getComputedStyle
  • render/dom/style-computed.ts & render/html/HTMLVisualElement.tsgetComputedStyle
  • animation/keyframes/DOMKeyframesResolver.tspageYOffset, getComputedStyle
  • animation/keyframes/KeyframesResolver.tsscrollTo
  • animation/utils/css-variables-conversion.tsgetComputedStyle
  • gestures/hover.ts & gestures/press/index.tsaddEventListener/removeEventListener (the [BUG]When using the drag gesture and calling stopPropagation inside onPointerUp, the scroll element starts following the cursor. #2794 capture-phase logic is preserved)
  • resize/handle-window.tsinnerWidth/innerHeight, addEventListener
  • utils/supports/scroll-timeline.tsScrollTimeline/ViewTimeline

render/VisualElement.ts (MotionCheckAppearSync) was already guarded, so it's untouched. projection/node/types.ts widens defaultParent to () => IProjectionNode | undefined (the sole caller already handles undefined).

Test

animation/interfaces/__tests__/visual-element-target.test.ts runs in the node test environment (where window is genuinely undefined, matching the reported runtime) and asserts animateTarget no longer throws. It fails against the unmodified code with window is not defined at the exact crash line, and passes with the guard.

Verification

  • motion-dom: builds clean (tsc + rollup + bundle-size check), all unit suites pass incl. the new regression test
  • framer-motion: builds clean (tsc --noEmitOnError), all 94 client suites + SSR suites pass — confirms no public-API or behavioural regression

Fixes #3735

🤖 Generated with Claude Code

Several motion-dom functions accessed `window` without a `typeof window`
guard, throwing in non-browser JS runtimes — or when `window` is shadowed
to undefined by an IIFE wrapper (e.g. Lynx's web runtime). The immediate
crash reported was `animateTarget` reading `window.MotionHandoffAnimation`,
but the same unguarded access existed across projection, keyframe
resolution, computed-style reads, gestures, resize handling and the
scroll-timeline support checks.

Add `typeof window !== "undefined"` guards at each site, matching the
existing pattern in render/utils/reduced-motion. `typeof` never throws on
an undefined binding, and every guard evaluates to true in a browser, so
existing behaviour is unchanged.

Fixes #3735

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds typeof window !== "undefined" guards across 11 source files in motion-dom to prevent crashes in non-browser JS runtimes (Node.js, Lynx's IIFE-wrapped web runtime) where window is undefined. Every guard evaluates to true in a standard browser, so existing browser behaviour is unchanged.

  • Crash fix: The primary crash (window.MotionHandoffAnimation in animateTarget) and all downstream unguarded window accesses (projection nodes, keyframe resolvers, gesture handlers, resize utilities, scroll-timeline feature detection) are now guarded with the established typeof window !== "undefined" pattern already used elsewhere in the codebase.
  • Type change: ProjectionNodeConfig.defaultParent return type is widened to IProjectionNode | undefined; the sole call site already handles undefined as a default constructor parameter.
  • Regression test: A new Jest test (@jest-environment node) confirms animateTarget no longer throws when window is genuinely absent.

Confidence Score: 4/5

Safe to merge for browser consumers; gesture handlers in hover.ts and press/index.ts have a minor state-stuck edge case that only affects non-browser runtimes with active pointer events

All guards are additive no-ops in browsers. The only non-trivial behavioural gap is in hover.ts and press/index.ts: when window is undefined but pointer events can still fire (e.g., Lynx's UI layer), the window-level pointerup/pointercancel cleanup listeners are never registered, so isPressed and isPressing can get stuck after a pointerdown. This doesn't affect any browser path and is a degraded-but-non-crashing state in the exact exotic environments this PR targets.

gestures/hover.ts and gestures/press/index.ts — the isPressed/isPressing cleanup gap is worth a follow-up if Lynx (or similar UI-capable runtimes) is a first-class target

Important Files Changed

Filename Overview
packages/motion-dom/src/animation/interfaces/tests/visual-element-target.test.ts New regression test for #3735; uses @jest-environment node so window is genuinely undefined, directly reproducing the reported crash
packages/motion-dom/src/animation/interfaces/visual-element-target.ts Adds typeof window !== "undefined" guard before window.MotionHandoffAnimation — the primary crash site from #3735
packages/motion-dom/src/gestures/hover.ts Guards window.addEventListener/removeEventListener for pointerup/pointercancel; isPressed can get stuck true when window is undefined but pointer events still fire (e.g., Lynx UI layer)
packages/motion-dom/src/gestures/press/index.ts Guards all window-level event listeners; isPressing WeakSet can retain entries when window is undefined and pointerup cleanup never fires; useGlobalTarget falls back to element-level listener when window is unavailable
packages/motion-dom/src/projection/node/create-projection-node.ts Adds window guards for MotionHasOptimisedAnimation, MotionCancelOptimisedAnimation, innerWidth, and MotionCancelOptimisedAnimation checks — all correct
packages/motion-dom/src/projection/node/types.ts Widens defaultParent return type to `IProjectionNode
packages/motion-dom/src/resize/handle-window.ts Early return in createWindowResizeHandler prevents any window access when window is undefined; cleanup path is safe because windowResizeHandler stays undefined and its function guard prevents removeEventListener from being reached
packages/motion-dom/src/utils/supports/scroll-timeline.ts Adds window guard before window.ScrollTimeline/window.ViewTimeline access; memoization correctly caches the false result in non-browser environments
packages/motion-dom/src/render/html/HTMLVisualElement.ts Returns {} as CSSStyleDeclaration when window is undefined; property accesses on the empty stub return undefined, which is safe for all callers
packages/motion-dom/src/animation/keyframes/DOMKeyframesResolver.ts Early-returns from measureInitialState/measureEndState when window is undefined, correctly preventing window.pageYOffset and getComputedStyle access; scroll-restore in KeyframesResolver is guarded too

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[animateTarget called] --> B{typeof window !== 'undefined'?}
    B -- No --> C[Skip MotionHandoffAnimation check\nReturn no-op animation]
    B -- Yes --> D[Read window.MotionHandoffAnimation\nProceed normally]

    E[hover / press pointerdown] --> F{typeof window !== 'undefined'?}
    F -- No --> G[Set isPressed=true\nSkip window pointerup/cancel listeners\nstate may get stuck]
    F -- Yes --> H[Register cleanup on window\nNormal gesture flow]

    I[createWindowResizeHandler] --> J{typeof window !== 'undefined'?}
    J -- No --> K[Return early\nwindowResizeHandler stays undefined]
    J -- Yes --> L[Register resize listener\nNormal resize flow]

    M[supportsScrollTimeline] --> N{typeof window !== 'undefined'?}
    N -- No --> O[Return false / memoize false]
    N -- Yes --> P[Check window.ScrollTimeline\nMemoize result]
Loading

Reviews (1): Last reviewed commit: "Guard window access for non-browser runt..." | Re-trigger Greptile

Comment on lines 86 to +99
const onPointerDown = () => {
isPressed = true
window.addEventListener(
"pointerup",
onPointerUp as EventListener,
eventOptions
)
window.addEventListener(
"pointercancel",
onPointerUp as EventListener,
eventOptions
)
if (typeof window !== "undefined") {
window.addEventListener(
"pointerup",
onPointerUp as EventListener,
eventOptions
)
window.addEventListener(
"pointercancel",
onPointerUp as EventListener,
eventOptions
)
}

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 Hover state can get permanently stuck in isPressed = true

When window is undefined (e.g., Lynx's main-thread runtime), onPointerDown sets isPressed = true but skips registering the pointerup/pointercancel cleanup on window. The only path that resets isPressed = false is onPointerUp, which is only registered on window. Subsequent pointerleave events will therefore always take the deferred-hover-end branch (deferredHoverEnd = true; return) and never call endHover, leaving the element permanently stuck in a "hovering while pressed" state for the rest of the element's lifetime. The same pattern exists in press/index.ts where isPressing can retain the target indefinitely once a pointerdown fires without a corresponding window-level cleanup.

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.

Missing typeof window guard in animateTarget causes TypeError in non-browser JS runtimes

1 participant