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";