diff --git a/package.json b/package.json index dec4ada54d..51d99b66ba 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "lucide-react": "^0.563.0", "mdast-util-from-markdown": "^2.0.2", "monaco-editor": "^0.55.1", - "motion": "^12.34.0", + "motion": "^13.4.0", "next": "^16", "next-intl": "^4.8.3", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a348db73f..d14093c26b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,8 +138,8 @@ importers: specifier: ^0.55.1 version: 0.55.1 motion: - specifier: ^12.34.0 - version: 12.34.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^13.4.0 + version: 13.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16 version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -578,12 +578,6 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emotion/is-prop-valid@1.4.0': - resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} - - '@emotion/memoize@0.9.0': - resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -4360,15 +4354,12 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.34.0: - resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} + framer-motion@13.4.0: + resolution: {integrity: sha512-HCpqKM9BTu6UYKtj0u6J1QNxojnnirNcghcSDZ+SkpiL3myxvtsgXPS3ZGEELC2eSma7YiA937rAEXtRyydSXQ==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -5399,21 +5390,18 @@ packages: monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} - motion-dom@12.34.0: - resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==} + motion-dom@13.3.0: + resolution: {integrity: sha512-AmAnB6pHdZ1vHGzWzuzteG6Q3j1lHKeX/lbST6jGlgm0cjvLUgaCk1xhOuPMOy5SgovN6wnnUdwFrkcsus7Ftg==} - motion-utils@12.29.2: - resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + motion-utils@13.3.0: + resolution: {integrity: sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==} - motion@12.34.0: - resolution: {integrity: sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA==} + motion@13.4.0: + resolution: {integrity: sha512-aiNSA29N+vgV5p5cJSB5cFzx8KTuuxB99z8zGtxsaqlwKtBK6sq8YgPRpkVQLkbJMOcLDU0q/ShdT95qpBy/0g==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -7280,14 +7268,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emotion/is-prop-valid@1.4.0': - dependencies: - '@emotion/memoize': 0.9.0 - optional: true - - '@emotion/memoize@0.9.0': - optional: true - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -11099,13 +11079,12 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.34.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@13.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.34.0 - motion-utils: 12.29.2 + motion-dom: 13.3.0 + motion-utils: 13.3.0 tslib: 2.8.1 optionalDependencies: - '@emotion/is-prop-valid': 1.4.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -12397,18 +12376,17 @@ snapshots: dompurify: 3.2.7 marked: 14.0.0 - motion-dom@12.34.0: + motion-dom@13.3.0: dependencies: - motion-utils: 12.29.2 + motion-utils: 13.3.0 - motion-utils@12.29.2: {} + motion-utils@13.3.0: {} - motion@12.34.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@13.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 13.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: - '@emotion/is-prop-valid': 1.4.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) diff --git a/src/components/tabs/motion-drag-end-contract.test.tsx b/src/components/tabs/motion-drag-end-contract.test.tsx new file mode 100644 index 0000000000..79ed7b78c5 --- /dev/null +++ b/src/components/tabs/motion-drag-end-contract.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest" +import { act, render, waitFor } from "@testing-library/react" +import { Reorder } from "motion/react" + +/** + * Dependency contract guard for issue #769 — "sticky" session tabs. + * + * Motion tears a drag gesture down from listeners it installs on `window`, not + * on the dragged element. Up to and including 12.40.x those listeners were + * registered in the BUBBLE phase, so any descendant calling + * `event.stopPropagation()` on `pointerup` / `pointercancel` kept the release + * from ever reaching `window`: `onDragStart` fired, `onDragEnd` never did, and + * the element stayed latched to the pointer. On the top tab strip that reads as + * a tab that will not let go after a click. + * + * Motion 12.41.0 moved those listeners to the CAPTURE phase (upstream #2794), + * where nothing below `window` can pre-empt them; 13.x carries the same fix. + * The repair therefore lives entirely in the dependency version, which means a + * lockfile refresh, a resolution change or a downgrade can silently reintroduce + * the bug with no source diff to notice. This test fails on 12.34.0 (the + * version 0.30.10 shipped) and passes on any build that has the fix. + * + * Scope of the claim: the mechanism below is bisected against the upstream + * tarballs and reproduces #769's symptom exactly, but the specific descendant + * in the live tab subtree that swallows the release was never pinned down, and + * 12.35–12.43 carry two further drag repairs ("draggable elements when layout + * updates due to surrounding element re-renders", and preserving in-flight drag + * values across React 19 reorder unmount/remount) that could contribute to the + * same report. This guards the one failure mode we can pin behaviourally. + * + * The damage is app-wide, not just to the one tab: Motion holds a MODULE-LEVEL + * per-axis drag lock (`motion-dom`'s `setDragLock`) that is released ONLY by the + * gesture teardown — `stop()` / `cancel()`, the same path that fires + * `onDragEnd`. Unmounting does not release it (`DragGesture.unmount` skips + * `endPanSession` outright while a drag is live, and `endPanSession` leaves the + * lock alone in any case), and nothing here calls `dragControls.cancel()`. So a + * single stranded gesture leaves `isDragging.x` latched and no `drag="x"` + * element anywhere — either tab strip included — can start a drag for the rest + * of the page's life. That is also why the second case below reports a missing + * `onDragStart` rather than a missing `onDragEnd` on an affected build: the + * first case already ate the lock. + * + * `TabItem` drives `Reorder.Item` exactly this way (`drag="x"` over a subtree of + * interactive descendants), so the group/item pair is the faithful shape to + * assert against rather than a bare `motion.div`. + */ + +// jsdom 25 still ships no `PointerEvent` constructor. Along this path Motion +// reads only `type`, `clientX`/`clientY`, `button`, `pointerType` and +// `isPrimary`, so a `MouseEvent` carrying a pointer event's type name exercises +// the same code: `pointerType` is undefined, which sends `isPrimaryPointer` +// down its non-mouse branch, where an undefined `isPrimary` counts as primary. +const pointerEvent = (type: string, clientX: number, clientY: number) => + new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY }) + +/** Motion's pan threshold is 3px; 40px clears it without ambiguity. */ +const DRAG_DISTANCE_PX = 40 + +/** + * Drives a full press → drag → release and hands back the spies. Motion starts + * and ends the gesture on its own frame loop, so each step is polled rather than + * slept on — a fixed delay would either be slack under parallel-worker load or + * needlessly slow. Failing to reach the drag START throws here, which is the + * correct outcome: a build that cannot begin the gesture has nothing to assert. + */ +async function dragAndRelease( + releaseType: "pointerup" | "pointercancel", + onDragStart: ReturnType, + onDragEnd: ReturnType +) { + const { getByTestId } = render( + {}}> + + {/* Stands in for any descendant that swallows the release — the close + button, a Radix trigger, a nested control. */} +