From 4b53d0dd7db280388e8468652c937f392a5ed158 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 15:08:11 -0300 Subject: [PATCH 1/2] fix: restore ProductsContext.tsx from 253cebc (last READY deploy) Commit 8818bea rewrote the entire file, removing 4 exports used by 21+ consumers: ProductsContext, useProductsContext, useProductsContextSafe, and the full lazy-fetch API (getProductById, getProductsByIds, registerProducts, batched-fetch, HMR recovery). This broke every production deploy since. --- src/contexts/ProductsContext.tsx | 252 ++++++++++++++++++++++--------- 1 file changed, 181 insertions(+), 71 deletions(-) diff --git a/src/contexts/ProductsContext.tsx b/src/contexts/ProductsContext.tsx index 133f644d0..cf88cbacb 100644 --- a/src/contexts/ProductsContext.tsx +++ b/src/contexts/ProductsContext.tsx @@ -17,97 +17,207 @@ import { logger } from '@/lib/logger'; // We use a global symbol to detect if multiple instances of this module are loaded const INSTANCE_KEY = Symbol.for('lovable_products_context_instance'); const globalObj = (typeof window !== 'undefined' ? window : {}) as Record; -const isDuplicateModule = !!globalObj[INSTANCE_KEY]; +const isDuplicateModule = globalObj[INSTANCE_KEY] && globalObj[INSTANCE_KEY] !== Math.random(); globalObj[INSTANCE_KEY] = globalObj[INSTANCE_KEY] || Math.random(); interface ProductsContextType { /** Cached products (only those that have been requested) */ products: Product[]; isLoading: boolean; - error: string | null; - fetchProducts: (ids: string[]) => Promise; - getProduct: (id: string) => Product | undefined; - invalidateCache: () => void; + getProductById: (id: string) => Product | undefined; + getProductsByIds: (ids: string[]) => Product[]; + /** Manually register products into the cache (e.g. from page-level queries) */ + registerProducts: (products: Product[]) => void; } -const ProductsContext = createContext(null); +export const ProductsContext = createContext(undefined); -interface ProductsProviderProps { - children: ReactNode; -} - -export function ProductsProvider({ children }: ProductsProviderProps) { - const [products, setProducts] = useState([]); +/** + * Lazy-loading ProductsProvider. + * Does NOT fetch all 6000+ products on startup. + * Instead, it fetches products on-demand when requested via getProductById/getProductsByIds. + * Products from page-level queries can be registered via registerProducts. + */ +export function ProductsProvider({ children }: { children: ReactNode }) { + const [cache, setCache] = useState>(new Map()); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const fetchedIdsRef = useRef>(new Set()); - const pendingRef = useRef>(new Set()); - - if (isDuplicateModule) { - logger.warn( - '[ProductsContext] Multiple module instances detected. ' + - 'This may cause stale data or hydration mismatches in HMR.', - ); - } + const [key, setKey] = useState(0); // Force re-mount key - const fetchProducts = useCallback(async (ids: string[]) => { - const newIds = ids.filter( - (id) => !fetchedIdsRef.current.has(id) && !pendingRef.current.has(id), - ); - if (!newIds.length) return; - - newIds.forEach((id) => pendingRef.current.add(id)); - setIsLoading(true); - setError(null); - - try { - const raw = await fetchPromobrindProducts(newIds); - const mapped = raw.map(mapPromobrindToProduct); - - setProducts((prev) => { - const existingIds = new Set(prev.map((p) => p.id)); - const next = [...prev]; - for (const p of mapped) { - if (!existingIds.has(p.id)) next.push(p); - } - return next; - }); - - newIds.forEach((id) => { - fetchedIdsRef.current.add(id); - pendingRef.current.delete(id); - }); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to fetch products'; - setError(msg); - newIds.forEach((id) => pendingRef.current.delete(id)); - } finally { - setIsLoading(false); + // HMR Recovery: If we detect a duplicate module via Global Symbol, force a re-mount + useEffect(() => { + if (isDuplicateModule) { + logger.warn('[ProductsContext] HMR duplication detected. Forcing Provider re-mount.'); + setKey((prev) => prev + 1); } }, []); - const getProduct = useCallback( - (id: string) => products.find((p) => p.id === id), - [products], + // Refs for stable callbacks + const cacheRef = useRef>(cache); + const fetchingRef = useRef>(new Set()); + const batchTimerRef = useRef | null>(null); + const batchIdsRef = useRef>(new Set()); + const mountedRef = useRef(true); + + useEffect(() => { + cacheRef.current = cache; + }, [cache]); + + // Cleanup on unmount (#11) + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (batchTimerRef.current) { + clearTimeout(batchTimerRef.current); + batchTimerRef.current = null; + } + }; + }, []); + + // Batched fetch: collects IDs over a microtask and fetches them together + const scheduleBatchFetch = useCallback(() => { + if (batchTimerRef.current) return; // already scheduled + + batchTimerRef.current = setTimeout(async () => { + const idsToFetch = [...batchIdsRef.current]; + batchIdsRef.current.clear(); + batchTimerRef.current = null; + + if (idsToFetch.length === 0) return; + + idsToFetch.forEach((id) => fetchingRef.current.add(id)); + if (mountedRef.current) setIsLoading(true); + + try { + const raw = await fetchPromobrindProducts({ + filters: { id: idsToFetch }, + limit: idsToFetch.length, + }); + const mapped = raw.map(mapPromobrindToProduct); + + if (mountedRef.current) { + setCache((prev) => { + const next = new Map(prev); + mapped.forEach((p) => next.set(p.id, p)); + return next; + }); + } + } catch (err) { + logger.warn('[ProductsContext] Failed to fetch products by IDs:', err); + } finally { + idsToFetch.forEach((id) => fetchingRef.current.delete(id)); + if (mountedRef.current) setIsLoading(false); + } + }, 50); // 50ms batching window + }, []); + + // Queue IDs for lazy fetching + const queueFetch = useCallback( + (ids: string[]) => { + const missing = ids.filter( + (id) => + !cacheRef.current.has(id) && !fetchingRef.current.has(id) && !batchIdsRef.current.has(id), + ); + if (missing.length === 0) return; + + missing.forEach((id) => batchIdsRef.current.add(id)); + scheduleBatchFetch(); + }, + [scheduleBatchFetch], + ); + + const getProductById = useCallback( + (id: string): Product | undefined => { + const cached = cacheRef.current.get(id); + if (!cached) { + queueFetch([id]); + } + return cached; + }, + [queueFetch], ); - const invalidateCache = useCallback(() => { - fetchedIdsRef.current.clear(); - pendingRef.current.clear(); - setProducts([]); - setError(null); + const getProductsByIds = useCallback( + (ids: string[]): Product[] => { + const found: Product[] = []; + const missing: string[] = []; + + for (const id of ids) { + const cached = cacheRef.current.get(id); + if (cached) { + found.push(cached); + } else { + missing.push(id); + } + } + + if (missing.length > 0) { + queueFetch(missing); + } + + return found; + }, + [queueFetch], + ); + + // Register products from external sources (e.g. page-level useProducts queries) + const registerProducts = useCallback((products: Product[]) => { + if (products.length === 0) return; + setCache((prev) => { + const next = new Map(prev); + let changed = false; + for (const p of products) { + if (!next.has(p.id)) { + next.set(p.id, p); + changed = true; + } + } + return changed ? next : prev; + }); }, []); - const value = useMemo( - () => ({ products, isLoading, error, fetchProducts, getProduct, invalidateCache }), - [products, isLoading, error, fetchProducts, getProduct, invalidateCache], + // Memoize the products array from cache + const products = useMemo(() => [...cache.values()], [cache]); + + return ( + + {children} + ); +} - return {children}; +/** + * No-op fallback returned when the context is unexpectedly missing. + * This prevents the entire app from crashing under HMR race conditions or + * Suspense edge-cases where a consumer mounts before the provider re-evaluates. + * Page-level data still loads via useProducts/useExternalProducts queries. + */ +const FALLBACK_CONTEXT: ProductsContextType = { + products: [], + isLoading: false, + getProductById: () => undefined, + getProductsByIds: () => [], + registerProducts: () => {}, +}; + +export function useProductsContext(): ProductsContextType { + const context = useContext(ProductsContext); + if (context === undefined) { + if (import.meta.env.DEV) { + logger.warn( + '[ProductsContext] useProductsContext called outside ProductsProvider — using fallback. ' + + 'This usually indicates an HMR module-duplication race; a full reload should fix it.', + ); + } + return FALLBACK_CONTEXT; + } + return context; } -export function useProducts() { - const ctx = useContext(ProductsContext); - if (!ctx) throw new Error('useProducts must be used within ProductsProvider'); - return ctx; +/** Safe version that returns null when outside ProductsProvider */ +export function useProductsContextSafe() { + return useContext(ProductsContext) ?? null; } From 21e467df0908559a4317bcf2193944116a404f44 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 15:08:45 -0300 Subject: [PATCH 2/2] fix: add 6 missing exports to query-config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CACHE_TIMES, GC_TIMES, QUERY_KEY_PREFIXES, PRODUTOS_QUERY_OPTIONS, TECNICAS_QUERY_OPTIONS, TABELAS_PRECO_QUERY_OPTIONS — all imported by hooks but never defined. Rollup masked these behind the ProductsContext error (stops at first missing export). Local vite build: ✓ 5743 modules, 0 errors. --- src/lib/query-config.ts | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/lib/query-config.ts b/src/lib/query-config.ts index f8e4001b3..022d65776 100644 --- a/src/lib/query-config.ts +++ b/src/lib/query-config.ts @@ -28,6 +28,61 @@ const STALE_DEFAULT = STALE_SEMI; // ───────────────────────────────────────────────────────────────────────────── const GC_DEFAULT = 15 * 60 * 1000; // 15 min (keeps rendered UI snappy on back-nav) +// ───────────────────────────────────────────────────────────────────────────── +// Named-tier re-exports for consumers that import by tier name +// ───────────────────────────────────────────────────────────────────────────── +export const CACHE_TIMES = { + /** Categories, suppliers, técnicas — almost never change */ + STABLE: STALE_STATIC, + /** Product catalog, supplier batches */ + SEMI: STALE_SEMI, + /** Quotes, notifications */ + LIVE: STALE_LIVE, + /** Connection health, bridge status */ + REALTIME: STALE_REALTIME, +} as const; + +export const GC_TIMES = { + /** Default GC time */ + DEFAULT: GC_DEFAULT, + /** Same as DEFAULT — used for técnicas/categories */ + TECNICAS: GC_DEFAULT, +} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Query-key prefixes for manual queryKey construction +// ───────────────────────────────────────────────────────────────────────────── +export const QUERY_KEY_PREFIXES = { + PRODUTO_PERSONALIZACAO: 'products', + TECNICAS: 'techniques', + TABELAS_PRECO: 'price-tables', + CATEGORIES: 'categories', + SUPPLIERS: 'suppliers', +} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Per-domain query option presets (spread into useQuery calls) +// ───────────────────────────────────────────────────────────────────────────── +export const PRODUTOS_QUERY_OPTIONS = { + staleTime: STALE_SEMI, + gcTime: GC_DEFAULT, + refetchOnWindowFocus: false, +} as const; + +export const TECNICAS_QUERY_OPTIONS = { + staleTime: STALE_STATIC, + gcTime: GC_DEFAULT, + refetchOnWindowFocus: false, + refetchOnMount: false, +} as const; + +export const TABELAS_PRECO_QUERY_OPTIONS = { + staleTime: STALE_STATIC, + gcTime: GC_DEFAULT, + refetchOnWindowFocus: false, + refetchOnMount: false, +} as const; + // ───────────────────────────────────────────────────────────────────────────── // Query-key prefix → stale-time routing // ─────────────────────────────────────────────────────────────────────────────