diff --git a/packages/app/src/composables/useCleanup.ts b/packages/app/src/composables/useCleanup.ts new file mode 100644 index 00000000..6eaccf70 --- /dev/null +++ b/packages/app/src/composables/useCleanup.ts @@ -0,0 +1,51 @@ +import { onUnmounted } from 'vue' + +/** + * Memory leak prevention composable. + * Tracks intervals, timeouts, listeners, and observers + * for automatic cleanup on component unmount. + */ +export function useCleanup() { + const intervals: ReturnType[] = [] + const timeouts: ReturnType[] = [] + const listeners: { target: EventTarget; event: string; handler: EventListenerOrEventListenerObject }[] = [] + const observers: { disconnect: () => void }[] = [] + + function addInterval(fn: () => void, ms: number): ReturnType { + const id = setInterval(fn, ms) + intervals.push(id) + return id + } + + function addTimeout(fn: () => void, ms: number): ReturnType { + const id = setTimeout(fn, ms) + timeouts.push(id) + return id + } + + function addListener(target: EventTarget, event: string, handler: EventListenerOrEventListenerObject, options?: AddEventListenerOptions) { + target.addEventListener(event, handler, options) + listeners.push({ target, event, handler }) + } + + function addObserver(observer: { disconnect: () => void }) { + observers.push(observer) + return observer + } + + onUnmounted(() => { + for (const id of intervals) clearInterval(id) + for (const id of timeouts) clearTimeout(id) + for (const { target, event, handler } of listeners) { + target.removeEventListener(event, handler) + } + for (const obs of observers) obs.disconnect() + }) + + return { + addInterval, + addTimeout, + addListener, + addObserver, + } +} diff --git a/packages/app/src/composables/useDeduplicatedFetch.ts b/packages/app/src/composables/useDeduplicatedFetch.ts new file mode 100644 index 00000000..6444082d --- /dev/null +++ b/packages/app/src/composables/useDeduplicatedFetch.ts @@ -0,0 +1,29 @@ +const inflight = new Map>() + +export function useDeduplicatedFetch() { + async function dedupFetch(url: string, options?: RequestInit): Promise { + // Generate a cache key from URL + body + const bodyStr = options?.body ? String(options.body) : '' + const key = `${options?.method ?? 'GET'}:${url}:${bodyStr}` + + const existing = inflight.get(key) + if (existing) return existing.then(r => r.clone()) + + const controller = new AbortController() + const mergedOptions = { ...options, signal: controller.signal } + + const promise = fetch(url, mergedOptions).finally(() => { + inflight.delete(key) + }) + + inflight.set(key, promise) + + return promise + } + + function cancelAll() { + inflight.clear() + } + + return { dedupFetch, cancelAll } +} diff --git a/packages/app/src/composables/useLazyImage.ts b/packages/app/src/composables/useLazyImage.ts new file mode 100644 index 00000000..4c7b70f6 --- /dev/null +++ b/packages/app/src/composables/useLazyImage.ts @@ -0,0 +1,40 @@ +import { ref, onMounted, onUnmounted, type Ref } from 'vue' + +const PLACEHOLDER = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjMWExYTFhIi8+PC9zdmc+' + +export function useLazyImage(imageRef: Ref, src: string) { + const isLoaded = ref(false) + const currentSrc = ref(PLACEHOLDER) + let observer: IntersectionObserver | null = null + + function onLoad() { + isLoaded.value = true + } + + onMounted(() => { + const el = imageRef.value + if (!el) return + + observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + currentSrc.value = src + el.src = src + el.addEventListener('load', onLoad, { once: true }) + observer?.disconnect() + } + } + }, + { rootMargin: '200px' } + ) + + observer.observe(el) + }) + + onUnmounted(() => { + observer?.disconnect() + }) + + return { isLoaded, currentSrc, placeholder: PLACEHOLDER } +} diff --git a/packages/app/src/composables/usePrefetch.ts b/packages/app/src/composables/usePrefetch.ts new file mode 100644 index 00000000..3adef00e --- /dev/null +++ b/packages/app/src/composables/usePrefetch.ts @@ -0,0 +1,32 @@ +const prefetchCache = new Map() +const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes + +export function usePrefetch() { + function getCached(key: string): T | null { + const entry = prefetchCache.get(key) + if (!entry) return null + if (Date.now() > entry.expiresAt) { + prefetchCache.delete(key) + return null + } + return entry.data as T + } + + function setCached(key: string, data: unknown) { + prefetchCache.set(key, { data, expiresAt: Date.now() + CACHE_DURATION }) + } + + async function prefetch(key: string, fetcher: () => Promise) { + if (getCached(key)) return + try { + const data = await fetcher() + setCached(key, data) + } catch { /* ignore prefetch errors */ } + } + + function clearCache() { + prefetchCache.clear() + } + + return { getCached, setCached, prefetch, clearCache } +} diff --git a/packages/app/src/composables/useSyncQueue.ts b/packages/app/src/composables/useSyncQueue.ts new file mode 100644 index 00000000..02e3679d --- /dev/null +++ b/packages/app/src/composables/useSyncQueue.ts @@ -0,0 +1,90 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +const STORAGE_KEY = 'aiui-sync-queue' + +interface QueuedOperation { + id: string + type: string + data: unknown + createdAt: number +} + +const queue = ref([]) +const hasQueuedItems = ref(false) + +function loadQueue() { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) queue.value = JSON.parse(stored) + hasQueuedItems.value = queue.value.length > 0 + } catch { /* ignore */ } +} + +function saveQueue() { + localStorage.setItem(STORAGE_KEY, JSON.stringify(queue.value)) + hasQueuedItems.value = queue.value.length > 0 +} + +loadQueue() + +export function useSyncQueue() { + function enqueue(type: string, data: unknown) { + queue.value.push({ + id: crypto.randomUUID(), + type, + data, + createdAt: Date.now(), + }) + saveQueue() + } + + async function processQueue(handler: (op: QueuedOperation) => Promise) { + const remaining: QueuedOperation[] = [] + for (const op of queue.value) { + try { + const success = await handler(op) + if (!success) remaining.push(op) + } catch { + remaining.push(op) + } + } + queue.value = remaining + saveQueue() + } + + function clearQueue() { + queue.value = [] + saveQueue() + } + + // Auto-retry on visibility change + let retryHandler: (() => void) | null = null + + function startAutoRetry(handler: (op: QueuedOperation) => Promise) { + retryHandler = () => { + if (document.visibilityState === 'visible' && queue.value.length > 0) { + processQueue(handler) + } + } + document.addEventListener('visibilitychange', retryHandler) + } + + onMounted(() => { + // Already loaded + }) + + onUnmounted(() => { + if (retryHandler) { + document.removeEventListener('visibilitychange', retryHandler) + } + }) + + return { + queue, + hasQueuedItems, + enqueue, + processQueue, + clearQueue, + startAutoRetry, + } +}