Skip to content

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

Open
mattgperry wants to merge 3 commits into
mainfrom
worktree-fix-issue-3735
Open

Guard animateTarget window access in non-browser runtimes (#3735)#3751
mattgperry wants to merge 3 commits into
mainfrom
worktree-fix-issue-3735

Conversation

@mattgperry

@mattgperry mattgperry commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Bug

animateTarget accessed window.MotionHandoffAnimation directly, throwing when window is unavailable or shadowed to undefined, as in the reported Lynx runtime.

Fix

Define isBrowser once at module initialization and use it to guard the handoff lookup. This keeps the fix limited to the reported crash and avoids repeating an environment check for each animated value.

Test

A Node-environment regression test verifies that animateTarget no longer throws without window.

Verification

  • Targeted Jest regression test passes
  • yarn build --filter=motion-dom passes

Fixes #3735

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.

Limit non-browser handling to animateTarget and use a module-level isBrowser flag so the animation loop avoids repeated environment checks.
@mattgperry mattgperry changed the title Guard window access for non-browser runtimes (#3735) Guard animateTarget window access in non-browser runtimes (#3735) Jul 27, 2026
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