diff --git a/apps/docs/public/rn-ui/llms.txt b/apps/docs/public/rn-ui/llms.txt index 2540957..1f8d467 100644 --- a/apps/docs/public/rn-ui/llms.txt +++ b/apps/docs/public/rn-ui/llms.txt @@ -1,7 +1,7 @@ # @masumdev/rn-ui — AI Developer & Context Guide > Production-ready, typed, flat-aesthetic React Native & Expo component library. -> Version: 0.4.0 | 61 Components | Monorepo: Masum Dev +> Version: 0.4.2 | 61 Components | Monorepo: Masum Dev This document is optimized for LLMs and AI coding assistants writing React Native / Expo applications using `@masumdev/rn-ui`. diff --git a/apps/docs/src/content/docs/rn-ui/changelog.mdx b/apps/docs/src/content/docs/rn-ui/changelog.mdx index 45dbe24..f5a4a67 100644 --- a/apps/docs/src/content/docs/rn-ui/changelog.mdx +++ b/apps/docs/src/content/docs/rn-ui/changelog.mdx @@ -8,11 +8,43 @@ description: Release history and version notes for @masumdev/rn-ui. import ChangelogTimeline from '@/components/ChangelogTimeline.astro'; export const releases = [ + { + version: 'v0.4.2', + date: '2026-09-21', + tag: 'Patch Release', + isLatest: true, + changes: [ + { + type: 'fix', + text: 'Progress Animation Memory Leak: Added cancelAnimation cleanup to the Progress component for both indeterminate (withRepeat) and determinate (withTiming) animation branches, preventing orphaned UI-thread worklets on unmount or prop changes.', + }, + ], + }, + { + version: 'v0.4.1', + date: '2026-09-21', + tag: 'Patch Release', + isLatest: false, + changes: [ + { + type: 'fix', + text: 'Spinner / Loader Rotation Crash-Proofing: Implemented safe degree normalization worklet (toSafeDeg) that strictly constrains rotation angles to finite positive values in [0, 360). Completely eliminated --<num>deg and NaNdeg errors that caused fatal React Native transform parser exceptions.', + }, + { + type: 'perf', + text: 'Ultra-Lightweight Spinner Execution: Extracted DotItem and BarItem as memoized components outside render loops to eliminate React Hook rule violations and memory leaks. Implemented selective animation execution (rotation for circular/ring, pulse for dots/bars) to save UI thread cycles.', + }, + { + type: 'fix', + text: 'Defensive SVG & Transform Bounds: Clamped dimensions and radii to strictly positive numbers to prevent invalid SVG circle rendering, and safeguarded rotation transforms across Toast and DatePicker.', + }, + ], + }, { version: 'v0.4.0', date: '2026-09-19', tag: 'Breaking Release', - isLatest: true, + isLatest: false, changes: [ { type: 'refactor', diff --git a/packages/rn-ui/CHANGELOG.md b/packages/rn-ui/CHANGELOG.md index d26f6e8..2b301f1 100644 --- a/packages/rn-ui/CHANGELOG.md +++ b/packages/rn-ui/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to **@masumdev/rn-ui** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.2] - 2026-09-21 + +### Fixed +- **Progress Animation Memory Leak**: Added `cancelAnimation` cleanup to the `Progress` component's `useEffect` for both indeterminate (`withRepeat`) and determinate (`withTiming`) animation branches. Previously, unmounting or toggling `indeterminate`/`animated` props could leave orphaned animation worklets on the UI thread. + +--- + +## [0.4.1] - 2026-09-21 + +### Fixed +- **Spinner / Loader Crash-Proofing & Safe Degrees**: + - Implemented worklet angle normalization (`toSafeDeg`) ensuring rotation angles are strictly finite positive numbers in the range `[0, 360)`. + - Eliminated the double negative crash (`--deg`) in counter-clockwise and reverse rotations which caused fatal native parser exceptions in React Native (`Invalid transform rotate: "--...deg"`). + - Eliminated `NaNdeg` and `-NaNdeg` exceptions caused by uninitialized or non-finite shared values. + - Safeguarded rotation transforms in `` and `` against `NaNdeg`. + +### Optimized +- **Ultra-Lightweight Spinner Execution & Hook Compliance**: + - Extracted `DotItem` and `BarItem` outside array render loops as top-level `React.memo` components, eliminating React Hook rule violations, preventing memory leaks, and removing GC stuttering. + - Implemented selective animation execution: `rotation` only runs for `circular` and `ring` variants, while `pulse` only runs for `dots` and `bars`. Idle animations are cancelled and reset to 0 to save UI thread cycles. + - Added defensive clamping for dimensions (`dimension >= 8`, `strokeWidth >= 1`, `radius >= 1`) to guarantee SVG `` attributes never receive non-positive or NaN values. + +--- + ## [0.4.0] - 2026-09-19 ### Removed diff --git a/packages/rn-ui/package.json b/packages/rn-ui/package.json index 0152445..2e7006f 100644 --- a/packages/rn-ui/package.json +++ b/packages/rn-ui/package.json @@ -1,6 +1,6 @@ { "name": "@masumdev/rn-ui", - "version": "0.4.0", + "version": "0.4.2", "description": "A reusable React Native UI kit with typed theme tokens, light/dark mode, and composable core components.", "main": "lib/commonjs/index.js", "module": "lib/module/index.js", diff --git a/packages/rn-ui/src/components/DatePicker.tsx b/packages/rn-ui/src/components/DatePicker.tsx index e1e0a05..70d9de9 100644 --- a/packages/rn-ui/src/components/DatePicker.tsx +++ b/packages/rn-ui/src/components/DatePicker.tsx @@ -127,12 +127,14 @@ function AnimatedWheelItem({ "clamp", ); - const rotateX = `${interpolate( - (scrollY.value - itemPosition) / itemHeight, + const rawVal = itemHeight > 0 ? (scrollY.value - itemPosition) / itemHeight : 0; + const interpDeg = interpolate( + Number.isFinite(rawVal) ? rawVal : 0, [-2, -1, 0, 1, 2], [36, 18, 0, -18, -36], "clamp", - )}deg`; + ); + const rotateX = `${Number.isFinite(interpDeg) ? interpDeg : 0}deg`; return { opacity, diff --git a/packages/rn-ui/src/components/Progress.tsx b/packages/rn-ui/src/components/Progress.tsx index 25588e2..0a2cd4b 100644 --- a/packages/rn-ui/src/components/Progress.tsx +++ b/packages/rn-ui/src/components/Progress.tsx @@ -6,6 +6,7 @@ import { type ViewStyle, } from "react-native"; import Animated, { + cancelAnimation, Easing, useAnimatedStyle, useSharedValue, @@ -77,14 +78,21 @@ export function Progress({ -1, false, ); - return; + return () => { + cancelAnimation(indeterminateOffset); + }; } if (!animated) { + cancelAnimation(width); width.value = progress; return; } width.value = withTiming(progress, { duration: 220, easing: Easing.out(Easing.quad) }); + + return () => { + cancelAnimation(width); + }; }, [animated, indeterminate, progress, width, indeterminateOffset]); const indicatorAnimatedStyle = useAnimatedStyle(() => { diff --git a/packages/rn-ui/src/components/Spinner.tsx b/packages/rn-ui/src/components/Spinner.tsx index 888ce0e..0c51651 100644 --- a/packages/rn-ui/src/components/Spinner.tsx +++ b/packages/rn-ui/src/components/Spinner.tsx @@ -15,6 +15,7 @@ import Animated, { useSharedValue, withRepeat, withTiming, + type SharedValue, } from "react-native-reanimated"; import Svg, { Circle } from "react-native-svg"; @@ -59,7 +60,7 @@ const SIZE_MAP: Record, number> = { }; function resolveDuration(speed: SpinnerSpeed): number { - if (typeof speed === "number") return speed; + if (typeof speed === "number" && Number.isFinite(speed) && speed > 0) return speed; switch (speed) { case "fast": return 550; @@ -72,12 +73,16 @@ function resolveDuration(speed: SpinnerSpeed): number { } function resolveDimension(size: SpinnerSize): number { - if (typeof size === "number") return size; + if (typeof size === "number") { + return Number.isFinite(size) && size > 0 ? size : 32; + } return SIZE_MAP[size] ?? 32; } function resolveStrokeWidth(dimension: number, customWidth?: number): number { - if (typeof customWidth === "number") return customWidth; + if (typeof customWidth === "number" && Number.isFinite(customWidth) && customWidth > 0) { + return Math.min(customWidth, dimension / 2.5); + } if (dimension <= 18) return 2; if (dimension <= 26) return 2.5; if (dimension <= 36) return 3.2; @@ -85,6 +90,106 @@ function resolveStrokeWidth(dimension: number, customWidth?: number): number { return 5; } +/** + * Worklet helper to calculate safe, positive rotation degrees strictly in the range [0, 360). + * Prevents invalid double negatives (e.g. "--10deg") or "NaNdeg" that cause fatal native crashes. + */ +function toSafeDeg(val: number, multiplier = 1): string { + "worklet"; + if (!Number.isFinite(val)) return "0deg"; + const raw = (val * multiplier) % 360; + const normalized = (raw + 360) % 360; + return `${normalized.toFixed(1)}deg`; +} + +// --------------------------------------------------------------------------- +// Standalone Memoized Items for Dots & Bars (Resolves Hook Rule Violation) +// --------------------------------------------------------------------------- + +interface DotItemProps { + pulse: SharedValue; + offset: number; + size: number; + color: string; +} + +const DotItem = React.memo(function DotItem({ + pulse, + offset, + size, + color, +}: DotItemProps) { + const dotStyle = useAnimatedStyle(() => { + const p = Number.isFinite(pulse.value) ? pulse.value : 0; + const phase = (p + offset) % 1; + const scale = interpolate(phase, [0, 0.5, 1], [0.4, 1.15, 0.4]); + const opacity = interpolate(phase, [0, 0.5, 1], [0.35, 1, 0.35]); + return { + transform: [{ scale: Number.isFinite(scale) ? scale : 1 }], + opacity: Number.isFinite(opacity) ? opacity : 1, + }; + }); + + return ( + + ); +}); + +interface BarItemProps { + pulse: SharedValue; + offset: number; + width: number; + height: number; + color: string; +} + +const BarItem = React.memo(function BarItem({ + pulse, + offset, + width, + height, + color, +}: BarItemProps) { + const barStyle = useAnimatedStyle(() => { + const p = Number.isFinite(pulse.value) ? pulse.value : 0; + const phase = (p + offset) % 1; + const scaleY = interpolate(phase, [0, 0.5, 1], [0.28, 1, 0.28]); + const opacity = interpolate(phase, [0, 0.5, 1], [0.4, 1, 0.4]); + return { + transform: [{ scaleY: Number.isFinite(scaleY) ? scaleY : 1 }], + opacity: Number.isFinite(opacity) ? opacity : 1, + }; + }); + + return ( + + ); +}); + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + function SpinnerComponent({ variant = "circular", tone = "primary", @@ -119,10 +224,14 @@ function SpinnerComponent({ ? colors.info : colors.primary); - const dimension = resolveDimension(size); - const strokeWidth = resolveStrokeWidth(dimension, customStrokeWidth); + const dimension = Math.max(8, resolveDimension(size)); + const strokeWidth = Math.max(1, resolveStrokeWidth(dimension, customStrokeWidth)); const duration = resolveDuration(speed); + // Selective animation requirement flags + const needsRotation = variant === "circular" || variant === "ring"; + const needsPulse = variant === "dots" || variant === "bars"; + // Animation drivers const rotation = useSharedValue(0); const pulse = useSharedValue(0); @@ -136,55 +245,65 @@ function SpinnerComponent({ return; } - rotation.value = 0; - rotation.value = withRepeat( - withTiming(360, { - duration, - easing: Easing.linear, - }), - -1, - false, - ); - - pulse.value = 0; - pulse.value = withRepeat( - withTiming(1, { - duration: Math.round(duration * 1.2), - easing: Easing.inOut(Easing.ease), - }), - -1, - true, - ); + if (needsRotation) { + rotation.value = 0; + rotation.value = withRepeat( + withTiming(360, { + duration, + easing: Easing.linear, + }), + -1, + false, + ); + } else { + cancelAnimation(rotation); + rotation.value = 0; + } + + if (needsPulse) { + pulse.value = 0; + pulse.value = withRepeat( + withTiming(1, { + duration: Math.round(duration * 1.2), + easing: Easing.inOut(Easing.ease), + }), + -1, + true, + ); + } else { + cancelAnimation(pulse); + pulse.value = 0; + } return () => { cancelAnimation(rotation); cancelAnimation(pulse); }; - }, [animated, duration, pulse, rotation]); + }, [animated, duration, needsPulse, needsRotation, pulse, rotation]); - // Animated styles + // Animated styles with crash-proof safe degrees const spinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotation.value}deg` }], + transform: [{ rotate: toSafeDeg(rotation.value, 1) }], })); const counterSpinStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `-${rotation.value * 1.3}deg` }], + transform: [{ rotate: toSafeDeg(rotation.value, -1.3) }], })); - // Calculations for circular arc - const radius = (dimension - strokeWidth) / 2; + // Calculations for circular arc with defensive bounds + const radius = Math.max(1, (dimension - strokeWidth) / 2); const circumference = 2 * Math.PI * radius; const arcLength = circumference * 0.72; - const strokeDashoffset = circumference - arcLength; + const strokeDashoffset = Math.max(0, circumference - arcLength); const backgroundTrackColor = trackColor || colors.backgroundSubtle; // Render core loader based on variant const renderLoader = () => { switch (variant) { case "ring": { - const innerDimension = Math.max(8, dimension * 0.65); - const innerStroke = Math.max(1.5, strokeWidth * 0.75); - const innerRadius = (innerDimension - innerStroke) / 2; + const innerDimension = Math.max(6, dimension * 0.65); + const innerStroke = Math.max(1, strokeWidth * 0.75); + const innerRadius = Math.max(1, (innerDimension - innerStroke) / 2); const innerCircumference = 2 * Math.PI * innerRadius; return ( @@ -246,36 +365,15 @@ function SpinnerComponent({ return ( - {[0, 0.33, 0.66].map((offset, idx) => { - const DotAnimatedView = () => { - const dotStyle = useAnimatedStyle(() => { - const phase = (pulse.value + offset) % 1; - const scale = interpolate(phase, [0, 0.5, 1], [0.4, 1.15, 0.4]); - const opacity = interpolate(phase, [0, 0.5, 1], [0.35, 1, 0.35]); - return { - transform: [{ scale }], - opacity, - }; - }); - - return ( - - ); - }; - - return ; - })} + {[0, 0.33, 0.66].map((offset, idx) => ( + + ))} ); } @@ -295,36 +393,16 @@ function SpinnerComponent({ gap, }} > - {[0, 0.25, 0.5, 0.75].map((offset, idx) => { - const BarAnimatedView = () => { - const barStyle = useAnimatedStyle(() => { - const phase = (pulse.value + offset) % 1; - const scaleY = interpolate(phase, [0, 0.5, 1], [0.28, 1, 0.28]); - const opacity = interpolate(phase, [0, 0.5, 1], [0.4, 1, 0.4]); - return { - transform: [{ scaleY }], - opacity, - }; - }); - - return ( - - ); - }; - - return ; - })} + {[0, 0.25, 0.5, 0.75].map((offset, idx) => ( + + ))} ); } diff --git a/packages/rn-ui/src/components/Toast.tsx b/packages/rn-ui/src/components/Toast.tsx index 8e119b3..15d86c3 100644 --- a/packages/rn-ui/src/components/Toast.tsx +++ b/packages/rn-ui/src/components/Toast.tsx @@ -583,9 +583,13 @@ export function Toast({ ], })); - const animatedIconStyle = useAnimatedStyle(() => ({ - transform: [{ rotate: `${rotateValue.value}deg` }], - })); + const animatedIconStyle = useAnimatedStyle(() => { + const raw = Number.isFinite(rotateValue.value) ? rotateValue.value : 0; + const safeDeg = ((raw % 360) + 360) % 360; + return { + transform: [{ rotate: `${safeDeg.toFixed(1)}deg` }], + }; + }); const isSolid = toast.variant === "solid"; const isFlat = toast.variant === "flat";