UNPKG

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