rough-native
Version:
Create graphics using HTML Canvas or SVG with a hand-drawn, sketchy, appearance. Features comprehensive React hooks, memory management, and React 18 concurrent rendering support.
703 lines (702 loc) • 27.7 kB
JavaScript
import { useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
import { RoughReactNativeSVG } from './react-native-svg';
import { CONFIG } from './config';
// Concurrent-safe store for managing RoughReactNativeSVG instances
class RoughInstanceStore {
constructor() {
this.instances = new Map();
this.listeners = new Set();
this.nextId = 0;
}
createInstance(config) {
const id = `${CONFIG.REACT_HOOK.ID_PREFIX}${this.nextId++}`;
const instance = new RoughReactNativeSVG(config);
this.instances.set(id, instance);
this.notifyListeners();
return id;
}
getInstance(id) {
return this.instances.get(id);
}
updateInstance(id, config) {
const existingInstance = this.instances.get(id);
if (existingInstance) {
existingInstance.dispose();
}
const newInstance = new RoughReactNativeSVG(config);
this.instances.set(id, newInstance);
this.notifyListeners();
}
deleteInstance(id) {
const instance = this.instances.get(id);
if (instance) {
instance.dispose();
this.instances.delete(id);
this.notifyListeners();
}
}
subscribe(callback) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
getSnapshot() {
// Return immutable snapshot for concurrent safety
return new Map(this.instances);
}
notifyListeners() {
this.listeners.forEach((callback) => callback());
}
// Cleanup method for testing/debugging
clear() {
this.instances.forEach((instance) => instance.dispose());
this.instances.clear();
this.notifyListeners();
}
}
// Global store instance
const roughStore = new RoughInstanceStore();
// Concurrent-safe shape generation store with adaptive memory management
class ShapeGenerationStore {
constructor() {
this.cache = new Map();
this.listeners = new Set();
this.pendingGenerations = new Set();
this.memoryMonitor = MemoryMonitor.getInstance();
}
get maxCacheSize() {
// Shape cache can be larger since shapes are more valuable to cache
return this.memoryMonitor.getRecommendedCacheSize() * CONFIG.MEMORY.SHAPE_CACHE_MULTIPLIER;
}
generateShape(key, generator) {
// Check if we have cached result
if (this.cache.has(key)) {
return this.cache.get(key);
}
// Under high memory pressure, limit concurrent generations
if (this.memoryMonitor.shouldAgressivelyCleanup() && this.pendingGenerations.size > 2) {
return null; // Defer generation under memory pressure
}
// Mark as pending to prevent duplicate work
if (this.pendingGenerations.has(key)) {
return null; // Return null for pending generations
}
// Check cache size before generating
if (this.cache.size >= this.maxCacheSize) {
this.performMemoryPressureCleanup();
}
this.pendingGenerations.add(key);
try {
const result = generator();
this.cache.set(key, result);
this.pendingGenerations.delete(key);
this.notifyListeners();
return result;
}
catch (error) {
this.pendingGenerations.delete(key);
// Cache error result to prevent retry storms (but only if not under memory pressure)
if (!this.memoryMonitor.shouldAgressivelyCleanup()) {
const errorResult = { props: {}, children: [], error: String(error) };
this.cache.set(key, errorResult);
}
this.notifyListeners();
return { props: {}, children: [], error: String(error) };
}
}
performMemoryPressureCleanup() {
const pressureLevel = this.memoryMonitor.getMemoryPressureLevel();
if (pressureLevel === 'high') {
// Aggressive cleanup - clear 75% of cache
const entries = Array.from(this.cache.entries());
const keepCount = Math.floor(entries.length * CONFIG.MEMORY.HIGH_PRESSURE_KEEP_PERCENT);
const toKeep = entries.slice(-keepCount); // Keep most recent
this.cache.clear();
toKeep.forEach(([key, value]) => this.cache.set(key, value));
}
else if (pressureLevel === 'medium') {
// Moderate cleanup - clear 50% of cache
const entries = Array.from(this.cache.entries());
const keepCount = Math.floor(entries.length * CONFIG.MEMORY.MEDIUM_PRESSURE_KEEP_PERCENT);
const toKeep = entries.slice(-keepCount); // Keep most recent
this.cache.clear();
toKeep.forEach(([key, value]) => this.cache.set(key, value));
}
// Low pressure - let normal cache size limits handle it
}
subscribe(callback) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
getSnapshot() {
return new Map(this.cache);
}
notifyListeners() {
this.listeners.forEach((callback) => callback());
}
clearCache() {
this.cache.clear();
this.pendingGenerations.clear();
this.notifyListeners();
}
}
const shapeStore = new ShapeGenerationStore();
// Memory pressure detection utility
class MemoryMonitor {
constructor() {
this.memoryPressureLevel = 'low';
this.lastMemoryCheck = 0;
this.checkInterval = CONFIG.MEMORY.MEMORY_CHECK_INTERVAL_MS;
}
static getInstance() {
if (!MemoryMonitor.instance) {
MemoryMonitor.instance = new MemoryMonitor();
}
return MemoryMonitor.instance;
}
getMemoryPressureLevel() {
const now = Date.now();
if (now - this.lastMemoryCheck > this.checkInterval) {
this.checkMemoryPressure();
this.lastMemoryCheck = now;
}
return this.memoryPressureLevel;
}
checkMemoryPressure() {
try {
// React Native memory detection
if (typeof global !== 'undefined' && global.performance && global.performance.memory) {
const memory = global.performance.memory;
const usedRatio = memory.usedJSHeapSize / memory.jsHeapSizeLimit;
if (usedRatio > CONFIG.MEMORY.HIGH_MEMORY_PRESSURE_THRESHOLD) {
this.memoryPressureLevel = 'high';
}
else if (usedRatio > CONFIG.MEMORY.MEDIUM_MEMORY_PRESSURE_THRESHOLD) {
this.memoryPressureLevel = 'medium';
}
else {
this.memoryPressureLevel = 'low';
}
return;
}
// Fallback: Browser memory API
if (typeof performance !== 'undefined' && performance.memory) {
const memory = performance.memory;
const usedRatio = memory.usedJSHeapSize / memory.jsHeapSizeLimit;
if (usedRatio > CONFIG.MEMORY.HIGH_MEMORY_PRESSURE_THRESHOLD) {
this.memoryPressureLevel = 'high';
}
else if (usedRatio > CONFIG.MEMORY.MEDIUM_MEMORY_PRESSURE_THRESHOLD) {
this.memoryPressureLevel = 'medium';
}
else {
this.memoryPressureLevel = 'low';
}
return;
}
// React Native specific checks
if (typeof global !== 'undefined') {
// Estimate memory pressure based on device characteristics
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
const isLowEndDevice = /Android.*4\.|Android.*[0-3]\./i.test(userAgent);
if (isLowEndDevice) {
this.memoryPressureLevel = 'high';
}
else {
this.memoryPressureLevel = 'medium';
}
return;
}
// Default to medium pressure for unknown environments
this.memoryPressureLevel = 'medium';
}
catch (error) {
// If memory detection fails, assume medium pressure
this.memoryPressureLevel = 'medium';
}
}
getRecommendedCacheSize() {
switch (this.memoryPressureLevel) {
case 'high':
return CONFIG.MEMORY.CACHE_SIZE_HIGH_PRESSURE; // Very conservative for low-memory devices
case 'medium':
return CONFIG.MEMORY.CACHE_SIZE_MEDIUM_PRESSURE; // Balanced for typical devices
case 'low':
return CONFIG.MEMORY.CACHE_SIZE_LOW_PRESSURE; // Generous for high-memory devices
default:
return CONFIG.MEMORY.CACHE_SIZE_DEFAULT;
}
}
shouldAgressivelyCleanup() {
return this.memoryPressureLevel === 'high';
}
}
// Optimized deep equality with adaptive caching based on memory pressure
class DeepEqualityCache {
constructor() {
this.cache = new WeakMap();
this.cacheCount = 0;
this.cacheHits = 0;
this.cacheMisses = 0;
this.memoryMonitor = MemoryMonitor.getInstance();
}
get maxCacheSize() {
return this.memoryMonitor.getRecommendedCacheSize();
}
get(a, b) {
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return undefined; // Don't cache primitives
}
const aCache = this.cache.get(a);
if (aCache) {
const result = aCache.get(b);
if (result !== undefined) {
this.cacheHits++;
return result;
}
}
this.cacheMisses++;
return undefined;
}
set(a, b, result) {
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return; // Don't cache primitives
}
// Adaptive cache management based on memory pressure
const currentMaxSize = this.maxCacheSize;
this.cacheCount++;
if (this.cacheCount > currentMaxSize) {
this.cache = new WeakMap();
this.cacheCount = 0;
// Under high memory pressure, be more aggressive about clearing
if (this.memoryMonitor.shouldAgressivelyCleanup()) {
// Force garbage collection hint (if available)
if (typeof global !== 'undefined' && global.gc) {
try {
global.gc();
}
catch (e) {
// Ignore GC errors
}
}
}
}
let aCache = this.cache.get(a);
if (!aCache) {
aCache = new WeakMap();
this.cache.set(a, aCache);
}
aCache.set(b, result);
// Only cache reverse direction if we're not under memory pressure
if (!this.memoryMonitor.shouldAgressivelyCleanup()) {
let bCache = this.cache.get(b);
if (!bCache) {
bCache = new WeakMap();
this.cache.set(b, bCache);
}
bCache.set(a, result);
}
}
getStats() {
return {
hits: this.cacheHits,
misses: this.cacheMisses,
hitRate: this.cacheHits / (this.cacheHits + this.cacheMisses),
memoryPressure: this.memoryMonitor.getMemoryPressureLevel(),
maxCacheSize: this.maxCacheSize,
currentCacheCount: this.cacheCount,
};
}
}
// Global cache instance
const deepEqualCache = new DeepEqualityCache();
// Optimized deep equality utility for React hooks
function deepEqual(a, b, depth = 0) {
// Early exits for performance
if (a === b)
return true;
if (depth > CONFIG.REACT_HOOK.MAX_RECURSION_DEPTH)
return false; // Prevent infinite recursion
// Quick type checks
const typeA = typeof a;
const typeB = typeof b;
if (typeA !== typeB)
return false;
// Handle null/undefined
if (a === null || b === null || a === undefined || b === undefined)
return a === b;
// Handle primitives (fastest path)
if (typeA !== 'object') {
return typeA === 'function' ? a === b : a === b;
}
// Check cache for objects
const cachedResult = deepEqualCache.get(a, b);
if (cachedResult !== undefined) {
return cachedResult;
}
let result;
// Handle special object types
if (a instanceof Date && b instanceof Date) {
result = a.getTime() === b.getTime();
}
else if (Array.isArray(a)) {
if (!Array.isArray(b) || a.length !== b.length) {
result = false;
}
else {
result = true;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i], depth + 1)) {
result = false;
break;
}
}
}
}
else if (Array.isArray(b)) {
result = false;
}
else {
// Handle plain objects
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
result = false;
}
else {
result = true;
// Use Set for O(1) key lookups with many keys
const keySetB = keysA.length > CONFIG.REACT_HOOK.KEY_LOOKUP_SET_THRESHOLD ? new Set(keysB) : keysB;
for (const key of keysA) {
const hasKey = Array.isArray(keySetB) ? keySetB.includes(key) : keySetB.has(key);
if (!hasKey || !deepEqual(a[key], b[key], depth + 1)) {
result = false;
break;
}
}
}
}
// Cache the result
deepEqualCache.set(a, b, result);
return result;
}
// Memory-safe deep memoization hook with proper cleanup
function useDeepMemo(value, deps) {
// Use separate refs to avoid closure capturing issues
const currentValueRef = useRef(value);
const previousValueRef = useRef(value);
const previousDepsRef = useRef(deps);
const isFirstRenderRef = useRef(true);
// Calculate if value has changed without capturing it in closure
const shouldUpdate = useMemo(() => {
// First render - always use the new value
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
return true;
}
// No dependencies provided - compare values directly
if (!deps && !previousDepsRef.current) {
return !deepEqual(value, previousValueRef.current);
}
// Dependencies provided - check if they changed
if (!deps || !previousDepsRef.current || deps.length !== previousDepsRef.current.length) {
return true;
}
// Deep compare each dependency
for (let i = 0; i < deps.length; i++) {
if (!deepEqual(deps[i], previousDepsRef.current[i])) {
return true;
}
}
return false;
}, deps || []);
// Update refs when necessary - avoid memory leaks by updating both refs
if (shouldUpdate) {
// Clear previous value to help GC
previousValueRef.current = currentValueRef.current;
currentValueRef.current = value;
previousDepsRef.current = deps;
}
// Cleanup effect to clear refs on unmount
useEffect(() => {
return () => {
// Clear all refs to prevent memory leaks
currentValueRef.current = null;
previousValueRef.current = null;
previousDepsRef.current = undefined;
};
}, []);
return currentValueRef.current;
}
// Enhanced hook that tracks memory usage and provides debugging
export function useDeepMemoWithDebug(value, deps, debugName) {
const result = useDeepMemo(value, deps);
// Development-only memory tracking
useEffect(() => {
if (process.env.NODE_ENV === 'development' && debugName) {
try {
const memUsage = typeof performance !== 'undefined' ? performance.memory : undefined;
if (memUsage && memUsage.usedJSHeapSize) {
console.debug(`useDeepMemo[${debugName}]: ${(memUsage.usedJSHeapSize / CONFIG.MEMORY.BYTES_TO_KB / CONFIG.MEMORY.KB_TO_MB).toFixed(2)}MB`);
}
}
catch (e) {
// Ignore memory access errors
}
}
});
return result;
}
// Export performance debugging utilities
export const debugUtils = {
getDeepEqualStats: () => deepEqualCache.getStats(),
clearDeepEqualCache: () => {
deepEqualCache.cache = new WeakMap();
},
// Concurrent rendering debugging
getRoughInstanceCount: () => roughStore.getSnapshot().size,
getShapeCacheSize: () => shapeStore.getSnapshot().size,
clearRoughInstances: () => roughStore.clear(),
clearShapeCache: () => shapeStore.clearCache(),
// Memory pressure monitoring
getMemoryPressure: () => MemoryMonitor.getInstance().getMemoryPressureLevel(),
getRecommendedCacheSize: () => MemoryMonitor.getInstance().getRecommendedCacheSize(),
forceMemoryCleanup: () => {
const monitor = MemoryMonitor.getInstance();
if (monitor.shouldAgressivelyCleanup()) {
shapeStore.clearCache();
deepEqualCache.cache = new WeakMap();
if (typeof global !== 'undefined' && global.gc) {
try {
global.gc();
}
catch (e) { /* ignore */ }
}
}
},
// Full cleanup for testing
clearAllCaches: () => {
deepEqualCache.cache = new WeakMap();
roughStore.clear();
shapeStore.clearCache();
},
};
/**
* React hook that provides a RoughReactNativeSVG instance with automatic cleanup
* Concurrent rendering safe with useSyncExternalStore
* @param config - Optional configuration for the rough instance
* @returns RoughReactNativeSVG instance that will be automatically disposed on unmount
*/
export function useRough(config) {
const instanceIdRef = useRef(null);
const stableConfig = useDeepMemo(config, [config]);
// Use concurrent-safe external store
const storeSnapshot = useSyncExternalStore(roughStore.subscribe.bind(roughStore), roughStore.getSnapshot.bind(roughStore), roughStore.getSnapshot.bind(roughStore) // Server snapshot (same as client)
);
// Get or create instance with concurrent safety
const rough = useMemo(() => {
if (!instanceIdRef.current) {
instanceIdRef.current = roughStore.createInstance(stableConfig);
}
else {
// Update existing instance if config changed
roughStore.updateInstance(instanceIdRef.current, stableConfig);
}
return roughStore.getInstance(instanceIdRef.current);
}, [stableConfig, storeSnapshot]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (instanceIdRef.current) {
roughStore.deleteInstance(instanceIdRef.current);
instanceIdRef.current = null;
}
};
}, []);
if (!rough) {
throw new Error('Failed to create RoughReactNativeSVG instance');
}
return rough;
}
/**
* React hook for creating shapes with automatic lifecycle management
* Concurrent rendering safe with cached shape generation
* @param shapeType - Type of shape to create
* @param params - Parameters for the shape
* @param options - Rough.js options
* @param config - Optional configuration for the rough instance
* @returns Rendered shape element
*/
export function useRoughShape(shapeType, params, options, config) {
const rough = useRough(config);
// Use deep memoization for complex parameters
const stableParams = useDeepMemo(params, [params]);
const stableOptions = useDeepMemo(options, [options]);
// Generate stable cache key for this shape
const shapeKey = useMemo(() => {
return `${shapeType}-${JSON.stringify(stableParams)}-${JSON.stringify(stableOptions)}`;
}, [shapeType, stableParams, stableOptions]);
// Use concurrent-safe shape generation store
const storeSnapshot = useSyncExternalStore(shapeStore.subscribe.bind(shapeStore), shapeStore.getSnapshot.bind(shapeStore), shapeStore.getSnapshot.bind(shapeStore));
// Generate shape with concurrent safety
const shape = useMemo(() => {
return shapeStore.generateShape(shapeKey, () => {
switch (shapeType) {
case 'line': {
const [x1, y1, x2, y2] = stableParams;
return rough.line(x1, y1, x2, y2, stableOptions);
}
case 'rectangle': {
const [x, y, width, height] = stableParams;
return rough.rectangle(x, y, width, height, stableOptions);
}
case 'ellipse': {
const [ex, ey, ew, eh] = stableParams;
return rough.ellipse(ex, ey, ew, eh, stableOptions);
}
case 'circle': {
const [cx, cy, diameter] = stableParams;
return rough.circle(cx, cy, diameter, stableOptions);
}
case 'linearPath': {
const points = stableParams;
return rough.linearPath(points, stableOptions);
}
case 'polygon': {
const polyPoints = stableParams;
return rough.polygon(polyPoints, stableOptions);
}
case 'arc': {
const [ax, ay, aw, ah, start, stop, closed] = stableParams;
return rough.arc(ax, ay, aw, ah, start, stop, closed || false, stableOptions);
}
case 'curve': {
const curvePoints = stableParams;
return rough.curve(curvePoints, stableOptions);
}
case 'path': {
const pathData = stableParams;
return rough.path(pathData, stableOptions);
}
default:
throw new Error(`Unknown shape type: ${shapeType}`);
}
});
}, [shapeKey, rough, shapeType, stableParams, stableOptions, storeSnapshot]);
// Return cached result or empty element if still generating
return shape || { props: {}, children: [] };
}
/**
* React hook for batch creating multiple shapes with shared configuration
* Concurrent rendering safe with batch shape generation
* @param shapes - Array of shape definitions
* @param config - Optional configuration for the rough instance
* @returns Array of rendered shape elements
*/
export function useRoughShapes(shapes, config) {
const rough = useRough(config);
// Use deep memoization for the shapes array
const stableShapes = useDeepMemo(shapes, [shapes]);
// Generate stable cache key for the entire batch
const batchKey = useMemo(() => {
return `batch-${JSON.stringify(stableShapes)}`;
}, [stableShapes]);
// Use concurrent-safe shape generation store
const storeSnapshot = useSyncExternalStore(shapeStore.subscribe.bind(shapeStore), shapeStore.getSnapshot.bind(shapeStore), shapeStore.getSnapshot.bind(shapeStore));
// Generate batch of shapes with concurrent safety
const renderedShapes = useMemo(() => {
return shapeStore.generateShape(batchKey, () => {
return stableShapes.map((shape, shapeIndex) => {
try {
const { type, params, options } = shape;
switch (type) {
case 'line': {
const [x1, y1, x2, y2] = params;
return rough.line(x1, y1, x2, y2, options);
}
case 'rectangle': {
const [x, y, width, height] = params;
return rough.rectangle(x, y, width, height, options);
}
case 'ellipse': {
const [ex, ey, ew, eh] = params;
return rough.ellipse(ex, ey, ew, eh, options);
}
case 'circle': {
const [cx, cy, diameter] = params;
return rough.circle(cx, cy, diameter, options);
}
case 'linearPath': {
const points = params;
return rough.linearPath(points, options);
}
case 'polygon': {
const polyPoints = params;
return rough.polygon(polyPoints, options);
}
case 'arc': {
const [ax, ay, aw, ah, start, stop, closed] = params;
return rough.arc(ax, ay, aw, ah, start, stop, closed || false, options);
}
case 'curve': {
const curvePoints = params;
return rough.curve(curvePoints, options);
}
case 'path': {
const pathData = params;
return rough.path(pathData, options);
}
default:
return { props: {}, children: [] };
}
}
catch (error) {
// Return empty element for individual shape errors
return { props: {}, children: [], error: `Shape ${shapeIndex} failed: ${String(error)}` };
}
});
});
}, [batchKey, rough, stableShapes, storeSnapshot]);
// Return cached results or empty array if still generating
return renderedShapes || [];
}
/**
* React hook that provides a stable rough instance that only recreates when config changes deeply
* @param config - Configuration for the rough instance
* @returns RoughReactNativeSVG instance
*/
export function useStableRough(config) {
const configRef = useRef(undefined);
const roughRef = useRef(null);
// Use deep equality instead of JSON.stringify for better performance
const configChanged = useMemo(() => {
if (!config && !configRef.current)
return false;
if (!config || !configRef.current)
return true;
return !deepEqual(config, configRef.current);
}, [config]);
const rough = useMemo(() => {
if (!roughRef.current || configChanged) {
if (roughRef.current) {
roughRef.current.dispose();
}
roughRef.current = new RoughReactNativeSVG(config);
configRef.current = config;
}
return roughRef.current;
}, [config, configChanged]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (roughRef.current) {
roughRef.current.dispose();
roughRef.current = null;
}
};
}, []);
return rough;
}