From cc829c8f6fc4da777c7ed5494f88c9e0c24c6520 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sat, 18 Apr 2026 15:41:22 -0700 Subject: [PATCH] Replace react-spring with motion, fix breaking changes in react-window/date-fns-tz/pure-rand. --- package.json | 2 +- pnpm-lock.yaml | 122 ++++++++++----------- src/components/common/AnimatedDiv.tsx | 4 +- src/components/hooks/useTimezone.ts | 10 +- src/components/metrics/ListTable.tsx | 51 +++++---- src/components/metrics/MetricCard.tsx | 24 +++- src/components/metrics/PerformanceCard.tsx | 18 +-- src/lib/date.ts | 4 +- src/lib/generate.ts | 7 +- 9 files changed, 132 insertions(+), 110 deletions(-) diff --git a/package.json b/package.json index ec6e3fed8..9306161fc 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,6 @@ "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", "@prisma/extension-read-replicas": "^0.5.0", - "@react-spring/web": "^10.0.3", "@svgr/cli": "^8.1.0", "@tanstack/react-query": "^5.99.0", "@umami/react-zen": "^0.245.0", @@ -89,6 +88,7 @@ "kafkajs": "^2.1.0", "lucide-react": "^1.8.0", "maxmind": "^5.0.6", + "motion": "^12.38.0", "next": "16.2.4", "next-intl": "4.9.1", "node-fetch": "^3.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff28acadc..616a54142 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@prisma/extension-read-replicas': specifier: ^0.5.0 version: 0.5.0(@prisma/client@7.7.0(prisma@7.7.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@6.0.3))(typescript@6.0.3)) - '@react-spring/web': - specifier: ^10.0.3 - version: 10.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@svgr/cli': specifier: ^8.1.0 version: 8.1.0(typescript@6.0.3) @@ -122,6 +119,9 @@ importers: maxmind: specifier: ^5.0.6 version: 5.0.6 + motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next: specifier: 16.2.4 version: 16.2.4(@babel/core@7.29.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2182,54 +2182,27 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-spring/animated@10.0.3': - resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-spring/animated@9.7.5': resolution: {integrity: sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@react-spring/core@10.0.3': - resolution: {integrity: sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-spring/core@9.7.5': resolution: {integrity: sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@react-spring/rafz@10.0.3': - resolution: {integrity: sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==} - '@react-spring/rafz@9.7.5': resolution: {integrity: sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==} - '@react-spring/shared@10.0.3': - resolution: {integrity: sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-spring/shared@9.7.5': resolution: {integrity: sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 - '@react-spring/types@10.0.3': - resolution: {integrity: sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==} - '@react-spring/types@9.7.5': resolution: {integrity: sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==} - '@react-spring/web@10.0.3': - resolution: {integrity: sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@react-spring/web@9.7.5': resolution: {integrity: sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==} peerDependencies: @@ -3987,6 +3960,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + 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: + optional: true + fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -4913,6 +4900,26 @@ packages: resolution: {integrity: sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==} engines: {node: '>=10', npm: '>=6'} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + 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: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8566,25 +8573,12 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@react-spring/animated@10.0.3(react@19.2.5)': - dependencies: - '@react-spring/shared': 10.0.3(react@19.2.5) - '@react-spring/types': 10.0.3 - react: 19.2.5 - '@react-spring/animated@9.7.5(react@19.2.5)': dependencies: '@react-spring/shared': 9.7.5(react@19.2.5) '@react-spring/types': 9.7.5 react: 19.2.5 - '@react-spring/core@10.0.3(react@19.2.5)': - dependencies: - '@react-spring/animated': 10.0.3(react@19.2.5) - '@react-spring/shared': 10.0.3(react@19.2.5) - '@react-spring/types': 10.0.3 - react: 19.2.5 - '@react-spring/core@9.7.5(react@19.2.5)': dependencies: '@react-spring/animated': 9.7.5(react@19.2.5) @@ -8592,35 +8586,16 @@ snapshots: '@react-spring/types': 9.7.5 react: 19.2.5 - '@react-spring/rafz@10.0.3': {} - '@react-spring/rafz@9.7.5': {} - '@react-spring/shared@10.0.3(react@19.2.5)': - dependencies: - '@react-spring/rafz': 10.0.3 - '@react-spring/types': 10.0.3 - react: 19.2.5 - '@react-spring/shared@9.7.5(react@19.2.5)': dependencies: '@react-spring/rafz': 9.7.5 '@react-spring/types': 9.7.5 react: 19.2.5 - '@react-spring/types@10.0.3': {} - '@react-spring/types@9.7.5': {} - '@react-spring/web@10.0.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@react-spring/animated': 10.0.3(react@19.2.5) - '@react-spring/core': 10.0.3(react@19.2.5) - '@react-spring/shared': 10.0.3(react@19.2.5) - '@react-spring/types': 10.0.3 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - '@react-spring/web@9.7.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@react-spring/animated': 9.7.5(react@19.2.5) @@ -10410,6 +10385,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + fs-extra@8.1.0: dependencies: graceful-fs: 4.2.11 @@ -11524,6 +11508,20 @@ snapshots: mmdb-lib@3.0.2: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + ms@2.1.3: {} mysql2@3.15.3: diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx index f9948971a..4832ac32b 100644 --- a/src/components/common/AnimatedDiv.tsx +++ b/src/components/common/AnimatedDiv.tsx @@ -1,3 +1,3 @@ -import { type AnimatedComponent, animated } from '@react-spring/web'; +import { motion } from 'motion/react'; -export const AnimatedDiv: AnimatedComponent = animated.div; +export const AnimatedDiv = motion.div; diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts index ef2553902..497c238b2 100644 --- a/src/components/hooks/useTimezone.ts +++ b/src/components/hooks/useTimezone.ts @@ -1,4 +1,4 @@ -import { formatInTimeZone, utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; +import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants'; import { getTimezone } from '@/lib/date'; import { setItem } from '@/lib/storage'; @@ -61,19 +61,19 @@ export function useTimezone() { }; const toUtc = (date: Date | string | number) => { - return zonedTimeToUtc(date, timezone); + return fromZonedTime(date, timezone); }; const fromUtc = (date: Date | string | number) => { - return utcToZonedTime(date, timezone); + return toZonedTime(date, timezone); }; const localToUtc = (date: Date | string | number) => { - return zonedTimeToUtc(date, localTimeZone); + return fromZonedTime(date, localTimeZone); }; const localFromUtc = (date: Date | string | number) => { - return utcToZonedTime(date, localTimeZone); + return toZonedTime(date, localTimeZone); }; const canonicalizeTimezone = (timezone: string): string => { diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx index f47d8ac0c..f06b7cec0 100644 --- a/src/components/metrics/ListTable.tsx +++ b/src/components/metrics/ListTable.tsx @@ -1,7 +1,7 @@ -import { config, useSpring } from '@react-spring/web'; import { Column, Grid, Row, Text } from '@umami/react-zen'; -import type { ReactNode } from 'react'; -import { FixedSizeList } from 'react-window'; +import { useSpring, useTransform } from 'motion/react'; +import { type ReactNode, useEffect } from 'react'; +import { List, type RowComponentProps } from 'react-window'; import { AnimatedDiv } from '@/components/common/AnimatedDiv'; import { Empty } from '@/components/common/Empty'; import { useMessages, useMobile } from '@/components/hooks'; @@ -62,7 +62,7 @@ export function ListTable({ ); }; - const ListTableRow = ({ index, style }) => { + const ListTableRow = ({ index, style }: RowComponentProps) => { return
{getRow(data[index], index)}
; }; @@ -82,14 +82,14 @@ export function ListTable({ {data?.length === 0 && } {virtualize && data.length > 0 ? ( - - {ListTableRow} - + ) : ( data.map(getRow) )} @@ -108,12 +108,21 @@ const AnimatedRow = ({ formatCount, isPhone, }) => { - const props = useSpring({ - width: percent, - y: !Number.isNaN(value) ? value : 0, - from: { width: 0, y: 0 }, - config: animate ? config.default : { duration: 0 }, - }); + const y = !Number.isNaN(value) ? value : 0; + const ySpring = useSpring(0, { stiffness: 170, damping: 26 }); + const widthSpring = useSpring(0, { stiffness: 170, damping: 26 }); + const yText = useTransform(ySpring, n => (formatCount ? formatCount(n) : formatLongNumber(n))); + const widthText = useTransform(widthSpring, n => `${n?.toFixed?.(0)}%`); + + useEffect(() => { + if (animate) { + ySpring.set(y); + widthSpring.set(percent); + } else { + ySpring.jump(y); + widthSpring.jump(percent); + } + }, [y, percent, animate, ySpring, widthSpring]); return ( {change} - - {formatCount ? props.y?.to(formatCount) : props.y?.to(formatLongNumber)} - + {yText} {showPercentage && ( @@ -151,7 +158,7 @@ const AnimatedRow = ({ color="muted" paddingLeft="3" > - {props.width.to(n => `${n?.toFixed?.(0)}%`)} + {widthText} )} diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx index 24768e3f4..f7bbb0a6f 100644 --- a/src/components/metrics/MetricCard.tsx +++ b/src/components/metrics/MetricCard.tsx @@ -1,6 +1,6 @@ -import { useSpring } from '@react-spring/web'; import { Button, Column, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; -import type { ReactNode } from 'react'; +import { useSpring, useTransform } from 'motion/react'; +import { type ReactNode, useEffect } from 'react'; import { AnimatedDiv } from '@/components/common/AnimatedDiv'; import { Info } from '@/components/icons'; import { ChangeLabel } from '@/components/metrics/ChangeLabel'; @@ -30,8 +30,20 @@ export const MetricCard = ({ }: MetricCardProps) => { const diff = value - change; const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0; - const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); - const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); + const x = Number(value) || 0; + const p = Number(pct) || 0; + const xSpring = useSpring(0, { stiffness: 170, damping: 26 }); + const pctSpring = useSpring(0, { stiffness: 170, damping: 26 }); + const valueText = useTransform(xSpring, n => formatValue(n)); + const pctText = useTransform(pctSpring, n => `${Math.abs(~~n)}%`); + + useEffect(() => { + xSpring.set(x); + }, [x, xSpring]); + + useEffect(() => { + pctSpring.set(p); + }, [p, pctSpring]); return ( )} - {props?.x?.to(x => formatValue(x))} + {valueText} {showChange && ( - {changeProps?.x?.to(x => `${Math.abs(~~x)}%`)} + {pctText} )} diff --git a/src/components/metrics/PerformanceCard.tsx b/src/components/metrics/PerformanceCard.tsx index 6a6345aaa..0851526d7 100644 --- a/src/components/metrics/PerformanceCard.tsx +++ b/src/components/metrics/PerformanceCard.tsx @@ -1,6 +1,6 @@ -import { config, useSpring } from '@react-spring/web'; import { Column, Text } from '@umami/react-zen'; -import { useRef } from 'react'; +import { useSpring, useTransform } from 'motion/react'; +import { useEffect, useRef } from 'react'; import { AnimatedDiv } from '@/components/common/AnimatedDiv'; import { Badge } from '@/components/common/Badge'; import { useMessages } from '@/components/hooks'; @@ -45,10 +45,14 @@ export const PerformanceCard = ({ const metricChanged = prevMetricRef.current !== metric; if (metricChanged) prevMetricRef.current = metric; - const spring = useSpring({ - value: !Number.isNaN(value) ? value : 0, - config: metricChanged ? { duration: 0 } : config.default, - }); + const target = !Number.isNaN(value) ? value : 0; + const spring = useSpring(target, { stiffness: 170, damping: 26 }); + const display = useTransform(spring, n => formatValue(n)); + + useEffect(() => { + if (metricChanged) spring.jump(target); + else spring.set(target); + }, [target, metricChanged, spring]); return ( - {spring.value.to(n => formatValue(n))} + {display} {t(labels[rating === 'needs-improvement' ? 'needsImprovement' : rating])} diff --git a/src/lib/date.ts b/src/lib/date.ts index 91af88f67..ae5ce2a76 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -38,7 +38,7 @@ import { subWeeks, subYears, } from 'date-fns'; -import { utcToZonedTime } from 'date-fns-tz'; +import { toZonedTime } from 'date-fns-tz'; import { getDateLocale } from '@/lib/lang'; import type { DateRange } from '@/lib/types'; @@ -164,7 +164,7 @@ export function parseDateRange( } const date = new Date(); - const now = timezone ? utcToZonedTime(date, timezone) : date; + const now = timezone ? toZonedTime(date, timezone) : date; const dateLocale = getDateLocale(locale); const { num = 1, unit } = parseDateValue(value); diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 8e25aa091..752c0b609 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -1,10 +1,11 @@ -import prand from 'pure-rand'; +import { uniformInt } from 'pure-rand/distribution/uniformInt'; +import { xoroshiro128plus } from 'pure-rand/generator/xoroshiro128plus'; const seed = Date.now() ^ (Math.random() * 0x100000000); -const rng = prand.xoroshiro128plus(seed); +const rng = xoroshiro128plus(seed); export function random(min: number, max: number) { - return prand.unsafeUniformIntDistribution(min, max, rng); + return uniformInt(rng, min, max); } export function getRandomChars(