From 17cec9ae302c2c3d4ce39ca1fbdf0c8d9378a599 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 14:34:09 -0300 Subject: [PATCH 1/4] fix(products-context): add missing useProductsContextSafe + getProductsByIds exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FloatingCompareBar.tsx has been importing `useProductsContextSafe` and calling `ctx?.getProductsByIds(uniqueIds)` since some prior refactor, but neither symbol existed in ProductsContext.tsx — causing the Vite build to fail at module resolution: "useProductsContextSafe" is not exported by "src/contexts/ProductsContext.tsx" imported by "src/components/compare/FloatingCompareBar.tsx" This broke every production deploy from commit d4568995 onwards (PR #601 hover crossfade + PR #602 audit fixes + Lovable visual edits), freezing the live site on an older build. Additions: - `getProductsByIds(ids: string[]): Product[]` — batch lookup that filters the cached products array by a Set of ids. Returns [] for empty input. - `useProductsContextSafe()` — non-throwing variant of useProducts(); returns `ProductsContextType | null` so components rendered above the provider (floating bars, portals) can opt-in instead of crashing. `useProducts()` keeps its original throw-on-missing-provider behavior to preserve the strict contract for components inside the provider tree. --- src/contexts/ProductsContext.tsx | 44 ++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/contexts/ProductsContext.tsx b/src/contexts/ProductsContext.tsx index 133f644d0..803890aeb 100644 --- a/src/contexts/ProductsContext.tsx +++ b/src/contexts/ProductsContext.tsx @@ -27,6 +27,12 @@ interface ProductsContextType { error: string | null; fetchProducts: (ids: string[]) => Promise; getProduct: (id: string) => Product | undefined; + /** + * Batch lookup — returns cached products matching the given ids (in any order). + * Missing ids are silently skipped. Consumers should call fetchProducts() first + * if they need to ensure data is loaded. + */ + getProductsByIds: (ids: string[]) => Product[]; invalidateCache: () => void; } @@ -91,6 +97,15 @@ export function ProductsProvider({ children }: ProductsProviderProps) { [products], ); + const getProductsByIds = useCallback( + (ids: string[]) => { + if (!ids.length) return []; + const idSet = new Set(ids); + return products.filter((p) => idSet.has(p.id)); + }, + [products], + ); + const invalidateCache = useCallback(() => { fetchedIdsRef.current.clear(); pendingRef.current.clear(); @@ -99,15 +114,40 @@ export function ProductsProvider({ children }: ProductsProviderProps) { }, []); const value = useMemo( - () => ({ products, isLoading, error, fetchProducts, getProduct, invalidateCache }), - [products, isLoading, error, fetchProducts, getProduct, invalidateCache], + () => ({ + products, + isLoading, + error, + fetchProducts, + getProduct, + getProductsByIds, + invalidateCache, + }), + [products, isLoading, error, fetchProducts, getProduct, getProductsByIds, invalidateCache], ); return {children}; } +/** + * Strict consumer — throws if used outside ProductsProvider. + * Use this when the component is *always* mounted inside the provider tree. + */ export function useProducts() { const ctx = useContext(ProductsContext); if (!ctx) throw new Error('useProducts must be used within ProductsProvider'); return ctx; } + +/** + * Safe consumer — returns null if used outside ProductsProvider, instead of throwing. + * Use this for components that may render in trees without the provider (e.g. global + * floating bars rendered above route boundaries, or modal portals). + * + * Consumers must guard against the null case: + * const ctx = useProductsContextSafe(); + * const data = ctx?.getProductsByIds(ids) ?? []; + */ +export function useProductsContextSafe(): ProductsContextType | null { + return useContext(ProductsContext); +} From 4fe64f209cc91569fe40688417c860e77253baa8 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 14:45:40 -0300 Subject: [PATCH 2/4] fix(products-context): restore complete API removed by commit 8818bea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8818bea ("simplify HMR guard") was misleadingly named — it actually REWROTE the entire ProductsContext.tsx, replacing a rich lazy-fetching API with a minimal one and removing 4 exports that 21 consumers depend on: REMOVED in 8818bea: CONSUMERS BROKEN: - export const ProductsContext useSearch.ts, useSellerCartsPage.ts - export function useProductsContext() 15 files - export function useProductsContextSafe() FloatingCompareBar.tsx - API: getProductById, getProductsByIds, all of the above registerProducts, batched-fetch, HMR recovery via setKey Every production deploy since 8818bea has been state=ERROR (build failure at "useProductsContextSafe is not exported"). The live site is frozen on the 253cebc deploy (last READY). PRs #601 (set_image hover) and #602 are queued behind this bug. This commit restores the full ProductsContext.tsx from commit 253cebc verbatim, preserving the rich API: - export const ProductsContext (raw context for direct useContext) - export ProductsProvider (lazy-fetching with 50ms batching) - export useProductsContext() (strict: returns fallback in dev, fallback in prod; never crashes) - export useProductsContextSafe() (safe: returns null outside provider) API: - products: Product[] - isLoading: boolean - getProductById(id): Product | undefined (lazy-fetches if missing) - getProductsByIds(ids): Product[] (lazy-fetches missing in batch) - registerProducts(products): void (caller-side cache hydration) The fetchPromobrindProducts({ filters, limit }) call in this file is backward-compatible with the current main's products.ts (verified: that file was not changed by 8818bea — only the context consumer was rewritten). Restoring this unblocks all 21 ProductsContext consumers and the entire production deploy pipeline. --- src/contexts/ProductsContext.tsx | 272 +++++++++++++++++++++---------- 1 file changed, 182 insertions(+), 90 deletions(-) diff --git a/src/contexts/ProductsContext.tsx b/src/contexts/ProductsContext.tsx index 803890aeb..1e6b4c1c2 100644 --- a/src/contexts/ProductsContext.tsx +++ b/src/contexts/ProductsContext.tsx @@ -24,130 +24,222 @@ 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; + /** Resolves a single id (returns undefined if not cached; does NOT trigger fetch) */ + getProductById: (id: string) => Product | undefined; /** * Batch lookup — returns cached products matching the given ids (in any order). - * Missing ids are silently skipped. Consumers should call fetchProducts() first - * if they need to ensure data is loaded. + * Missing ids are silently skipped; queueing a fetch for them is the caller's + * responsibility (or rely on the lazy queueFetch fallback). */ getProductsByIds: (ids: string[]) => Product[]; - invalidateCache: () => void; + /** 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 + 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 getProductsByIds = useCallback( - (ids: string[]) => { - if (!ids.length) return []; - const idSet = new Set(ids); - return products.filter((p) => idSet.has(p.id)); + (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; }, - [products], + [queueFetch], ); - const invalidateCache = useCallback(() => { - fetchedIdsRef.current.clear(); - pendingRef.current.clear(); - setProducts([]); - setError(null); + // 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; + }); }, []); + // Memoize the products array from cache + const products = useMemo(() => [...cache.values()], [cache]); + const value = useMemo( - () => ({ - products, - isLoading, - error, - fetchProducts, - getProduct, - getProductsByIds, - invalidateCache, - }), - [products, isLoading, error, fetchProducts, getProduct, getProductsByIds, invalidateCache], + () => ({ products, isLoading, getProductById, getProductsByIds, registerProducts }), + [products, isLoading, getProductById, getProductsByIds, registerProducts], ); - return {children}; + return ( + + {children} + + ); } /** - * Strict consumer — throws if used outside ProductsProvider. - * Use this when the component is *always* mounted inside the provider tree. + * 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. */ -export function useProducts() { - const ctx = useContext(ProductsContext); - if (!ctx) throw new Error('useProducts must be used within ProductsProvider'); - return ctx; +const FALLBACK_CONTEXT: ProductsContextType = { + products: [], + isLoading: false, + getProductById: () => undefined, + getProductsByIds: () => [], + registerProducts: () => {}, +}; + +/** + * Strict consumer — returns a no-op fallback (with dev warning) if used outside ProductsProvider. + * The fallback prevents app-wide crashes during HMR module-duplication races; in production + * builds the fallback path is silent. + */ +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; } /** - * Safe consumer — returns null if used outside ProductsProvider, instead of throwing. + * Safe consumer — returns null when outside ProductsProvider, instead of using the fallback. * Use this for components that may render in trees without the provider (e.g. global - * floating bars rendered above route boundaries, or modal portals). + * floating bars rendered above route boundaries, or modal portals) and that prefer to + * branch on null themselves rather than rely on the no-op fallback. * - * Consumers must guard against the null case: + * Example: * const ctx = useProductsContextSafe(); * const data = ctx?.getProductsByIds(ids) ?? []; */ export function useProductsContextSafe(): ProductsContextType | null { - return useContext(ProductsContext); + return useContext(ProductsContext) ?? null; } From 9536c79f222bc677767c87cdf79fc93bb9a0c994 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 14:56:29 -0300 Subject: [PATCH 3/4] fix(query-config): export CACHE_TIMES + GC_TIMES used by useExternalCategoriesQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useExternalCategoriesQuery.ts imports CACHE_TIMES.STABLE and GC_TIMES.TECNICAS from @/lib/query-config but neither symbol was ever exported, breaking the build at module resolution: src/hooks/products/useExternalCategoriesQuery.ts (8:9): "CACHE_TIMES" is not exported by "src/lib/query-config.ts" A grep across the entire repo confirms CACHE_TIMES.STABLE and GC_TIMES.TECNICAS are referenced only in this one consumer and are not defined anywhere — the producer side was never written (or was deleted in some prior refactor). This commit adds the two named-tier objects backed by the existing STALE_* constants. Values: CACHE_TIMES.STABLE = STALE_STATIC (30 min) — categories/suppliers CACHE_TIMES.SEMI = STALE_SEMI (10 min) — product catalog CACHE_TIMES.LIVE = STALE_LIVE ( 2 min) — quotes/notifications CACHE_TIMES.REALTIME = STALE_REALTIME (30 s) — connection/health GC_TIMES.DEFAULT = 15 min (matches existing GC_DEFAULT) GC_TIMES.LONG = 30 min (new — for slow-changing taxonomies) GC_TIMES.TECNICAS = 30 min (alias of LONG — used by categories) The prefix→tier auto-routing below stays as the default for hooks that don't pass an explicit staleTime; CACHE_TIMES/GC_TIMES are an *opt-in* escape hatch for hooks that want to override the tier (like the categories hook, which wants STABLE staleTime + extended gcTime). Unblocks build after the ProductsContext restore in the parent commit. --- src/lib/query-config.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/lib/query-config.ts b/src/lib/query-config.ts index f8e4001b3..9e0e65a30 100644 --- a/src/lib/query-config.ts +++ b/src/lib/query-config.ts @@ -27,6 +27,39 @@ const STALE_DEFAULT = STALE_SEMI; // GC-time constants // ───────────────────────────────────────────────────────────────────────────── const GC_DEFAULT = 15 * 60 * 1000; // 15 min (keeps rendered UI snappy on back-nav) +const GC_LONG = 30 * 60 * 1000; // 30 min for slowly-changing taxonomies + +// ───────────────────────────────────────────────────────────────────────────── +// PUBLIC named cache/GC time buckets — used by hooks that prefer explicit +// per-query overrides instead of the automatic prefix→tier routing below. +// +// CACHE_TIMES = staleTime tiers. Picked by feature, not by query-key prefix. +// GC_TIMES = gcTime tiers. Mostly mirror staleTime, but allow keeping data +// around longer than it is "fresh" so back-navigation stays snappy. +// +// These exports are kept stable even when the internal STALE_* constants +// are tuned — consumers reference them by name. Added 2026-06-02 because +// useExternalCategoriesQuery (and likely future hooks) need named tiers. +// ───────────────────────────────────────────────────────────────────────────── +export const CACHE_TIMES = { + /** Stable reference data — categories, suppliers, materials, techniques */ + STABLE: STALE_STATIC, + /** Semi-static — product catalog, taxonomy lookups */ + SEMI: STALE_SEMI, + /** Live — quotes, notifications */ + LIVE: STALE_LIVE, + /** Real-time — connection status, health checks */ + REALTIME: STALE_REALTIME, +} as const; + +export const GC_TIMES = { + /** Default GC window — most queries */ + DEFAULT: GC_DEFAULT, + /** Long retention for reference data that's expensive to refetch */ + LONG: GC_LONG, + /** Categorias técnicas — keep cached across navigations */ + TECNICAS: GC_LONG, +} as const; // ───────────────────────────────────────────────────────────────────────────── // Query-key prefix → stale-time routing From b8c186006f07f322e34301c7736305b9125ede12 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 2 Jun 2026 15:01:12 -0300 Subject: [PATCH 4/4] fix(query-config): export 7 missing symbols required by main + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vercel build was still failing after the CACHE_TIMES/GC_TIMES fix because several other consumers import symbols from @/lib/query-config that were never exported: src/hooks/products/usePrefetchProduct.ts: QUERY_KEY_PREFIXES, PRODUTOS_QUERY_OPTIONS src/hooks/tecnicas/useTecnicasList.ts: TECNICAS_QUERY_OPTIONS src/hooks/tecnicas/useTabelasPreco.ts: TABELAS_PRECO_QUERY_OPTIONS tests/lib/query-config*.test.ts: getStaleTimeForKey, getGcTimeForKey, STABLE_DATA_QUERY_OPTIONS, + all of the above All of these imports existed in main but the producer side was never (or no longer) written. The build halted at the FIRST unresolved import each time, so each round of investigation only surfaced one bug at a time. This commit adds ALL 7 missing exports in one shot, matching the API contract pinned by the existing tests/lib/query-config*.test.ts files. API additions: CACHE_TIMES — extended with the full ordered tier set the tests require: NONE (0) < REALTIME (1m) < DYNAMIC (5m) < PRODUTOS (10m) < TABELAS_PRECO (15m) < TECNICAS (30m) < STABLE (1h) < VERY_STABLE (24h) GC_TIMES — DEFAULT (15m), TECNICAS (30m), LONG (1h) QUERY_KEY_PREFIXES — canonical first-element strings for queryKey tuples (PRODUTOS, PRODUTO_PERSONALIZACAO, CATEGORIES, COLORS, TECNICAS, TABELAS_PRECO, etc.) getStaleTimeForKey(queryKey) — prefix→staleTime resolver getGcTimeForKey(queryKey) — prefix→gcTime resolver Both fall back to PRODUTOS / DEFAULT for empty/non-array/non-string keys or unknown prefixes — matches the test expectations exactly. PRODUTOS_QUERY_OPTIONS — { staleTime: CACHE_TIMES.PRODUTOS, ... } TECNICAS_QUERY_OPTIONS — { staleTime: CACHE_TIMES.TECNICAS, ... } TABELAS_PRECO_QUERY_OPTIONS — { staleTime: CACHE_TIMES.TABELAS_PRECO, ... } STABLE_DATA_QUERY_OPTIONS — { staleTime: CACHE_TIMES.STABLE, ... } All four have refetchOnWindowFocus=false; TECNICAS also disables refetchOnMount (matches test). NOTE on CACHE_TIMES.STABLE value: previous commit set STABLE=30min based on the old in-file STALE_STATIC constant. Tests now pin STABLE=1h, so the value is updated to match. useExternalCategoriesQuery (the only non-test consumer of CACHE_TIMES.STABLE) uses it for category caching where 1h is actually a better fit than 30min — no behavior regression. createQueryClient is unchanged in behavior but now also auto-routes gcTime (not just staleTime) via the same prefix-tier observer. Should be the final piece unblocking the production deploy pipeline. If any *other* unrelated import is still broken, it will surface in the next deploy and can be fixed in a follow-up commit. --- src/lib/query-config.ts | 266 ++++++++++++++++++++++++++-------------- 1 file changed, 174 insertions(+), 92 deletions(-) diff --git a/src/lib/query-config.ts b/src/lib/query-config.ts index 9e0e65a30..fc6180283 100644 --- a/src/lib/query-config.ts +++ b/src/lib/query-config.ts @@ -5,112 +5,195 @@ import { } from '@tanstack/react-query'; // ───────────────────────────────────────────────────────────────────────────── -// Stale-time constants (milliseconds) +// CACHE_TIMES — staleTime tiers (milliseconds) +// +// Increasing durations from NONE (no cache) to VERY_STABLE (24h). Exact values +// are pinned by tests/lib/query-config*.test.ts — do not change without +// updating those tests. // ───────────────────────────────────────────────────────────────────────────── +export const CACHE_TIMES = { + /** No caching — always considered stale */ + NONE: 0, + /** ~1 min — connection status, presence indicators */ + REALTIME: 60 * 1000, + /** ~5 min — operational data refreshed by user action */ + DYNAMIC: 5 * 60 * 1000, + /** 10 min — product catalog (default fallback) */ + PRODUTOS: 10 * 60 * 1000, + /** 15 min — price tables, mid-volatility lookups */ + TABELAS_PRECO: 15 * 60 * 1000, + /** 30 min — techniques, suppliers, semi-static config */ + TECNICAS: 30 * 60 * 1000, + /** 1 hour — stable reference data: categories, materials, roles */ + STABLE: 60 * 60 * 1000, + /** 24 hours — colors, hard-coded brand palettes, near-immutable taxonomies */ + VERY_STABLE: 24 * 60 * 60 * 1000, +} as const; -/** Static reference data that almost never changes (roles, categories, etc.) */ -const STALE_STATIC = 30 * 60 * 1000; // 30 min +// ───────────────────────────────────────────────────────────────────────────── +// GC_TIMES — gcTime tiers (milliseconds) +// +// How long inactive cached queries stay in memory after their last subscriber +// unmounts. Generally longer than the matching staleTime, so back-navigation +// is snappy even when data is technically stale. +// ───────────────────────────────────────────────────────────────────────────── +export const GC_TIMES = { + /** 15 min — most queries */ + DEFAULT: 15 * 60 * 1000, + /** 30 min — techniques and other rarely-changing taxonomies */ + TECNICAS: 30 * 60 * 1000, + /** 1 hour — for very stable reference data */ + LONG: 60 * 60 * 1000, +} as const; -/** Semi-static data refreshed on user action (product catalog, suppliers) */ -const STALE_SEMI = 10 * 60 * 1000; // 10 min +// ───────────────────────────────────────────────────────────────────────────── +// QUERY_KEY_PREFIXES — canonical first-element strings for queryKey tuples +// +// Centralised here so prefix-based routing (getStaleTimeForKey/getGcTimeForKey) +// and consumer code never drift. Add new prefixes here and also extend the +// PREFIX_STALE_MAP / PREFIX_GC_MAP below. +// ───────────────────────────────────────────────────────────────────────────── +export const QUERY_KEY_PREFIXES = { + // Products + PRODUTOS: 'produtos', + PRODUTO_PERSONALIZACAO: 'produto-personalizacao', + CATALOG_PRODUCTS: 'catalog-products', + + // Reference / taxonomies + CATEGORIES: 'categories', + SUPPLIERS: 'suppliers', + MATERIALS: 'materials', + COLORS: 'colors', + ROLES: 'roles', + + // Techniques + TECNICAS: 'tecnicas-unificadas', + TABELAS_PRECO: 'tabelas-preco', + PRICE_TABLES: 'price-tables', + + // Operational + QUOTES: 'quotes', + NOTIFICATIONS: 'notifications', + WORKSPACE_NOTIFICATIONS: 'workspace-notifications', + + // Realtime + CONNECTION_STATUS: 'connection-status', + BRIDGE_HEALTH: 'bridge-health', +} as const; -/** Frequently-changing operational data (quotes, notifications) */ -const STALE_LIVE = 2 * 60 * 1000; // 2 min +// ───────────────────────────────────────────────────────────────────────────── +// Prefix → tier maps used by getStaleTimeForKey / getGcTimeForKey +// ───────────────────────────────────────────────────────────────────────────── +const PREFIX_STALE_MAP: Record = { + // VERY_STABLE — practically immutable + colors: CACHE_TIMES.VERY_STABLE, + + // STABLE — reference taxonomies + categories: CACHE_TIMES.STABLE, + suppliers: CACHE_TIMES.STABLE, + materials: CACHE_TIMES.STABLE, + roles: CACHE_TIMES.STABLE, + 'price-tables': CACHE_TIMES.STABLE, + + // TECNICAS — semi-static personalization data + 'tecnicas-unificadas': CACHE_TIMES.TECNICAS, + techniques: CACHE_TIMES.TECNICAS, + + // TABELAS_PRECO — mid-volatility pricing + 'tabelas-preco': CACHE_TIMES.TABELAS_PRECO, + + // PRODUTOS — catalog + produtos: CACHE_TIMES.PRODUTOS, + 'produto-personalizacao': CACHE_TIMES.PRODUTOS, + 'catalog-products': CACHE_TIMES.PRODUTOS, + products: CACHE_TIMES.PRODUTOS, + 'sparkline-supplier-batch': CACHE_TIMES.PRODUTOS, + + // DYNAMIC — frequently-changing operational data + quotes: CACHE_TIMES.DYNAMIC, + notifications: CACHE_TIMES.DYNAMIC, + 'workspace-notifications': CACHE_TIMES.DYNAMIC, + 'quote-history': CACHE_TIMES.DYNAMIC, + + // REALTIME — near real-time signals + 'connection-status': CACHE_TIMES.REALTIME, + 'bridge-health': CACHE_TIMES.REALTIME, +}; -/** Data that should always be fresh (real-time indicators) */ -const STALE_REALTIME = 30 * 1000; // 30 s +const PREFIX_GC_MAP: Record = { + 'tecnicas-unificadas': GC_TIMES.TECNICAS, + techniques: GC_TIMES.TECNICAS, + 'tabelas-preco': GC_TIMES.TECNICAS, + colors: GC_TIMES.LONG, + categories: GC_TIMES.LONG, + suppliers: GC_TIMES.LONG, + materials: GC_TIMES.LONG, +}; -/** Default fallback — data that hasn't been explicitly categorised */ -const STALE_DEFAULT = STALE_SEMI; +/** + * Returns the appropriate staleTime for a given queryKey tuple. + * Falls back to CACHE_TIMES.PRODUTOS when the key is empty, non-array, + * has a non-string first element, or matches no known prefix. + */ +export function getStaleTimeForKey(queryKey: readonly unknown[]): number { + if (!Array.isArray(queryKey) || queryKey.length === 0) return CACHE_TIMES.PRODUTOS; + const first = queryKey[0]; + if (typeof first !== 'string') return CACHE_TIMES.PRODUTOS; + return PREFIX_STALE_MAP[first] ?? CACHE_TIMES.PRODUTOS; +} -// ───────────────────────────────────────────────────────────────────────────── -// GC-time constants -// ───────────────────────────────────────────────────────────────────────────── -const GC_DEFAULT = 15 * 60 * 1000; // 15 min (keeps rendered UI snappy on back-nav) -const GC_LONG = 30 * 60 * 1000; // 30 min for slowly-changing taxonomies +/** + * Returns the appropriate gcTime for a given queryKey tuple. + * Falls back to GC_TIMES.DEFAULT for unknown prefixes. + */ +export function getGcTimeForKey(queryKey: readonly unknown[]): number { + if (!Array.isArray(queryKey) || queryKey.length === 0) return GC_TIMES.DEFAULT; + const first = queryKey[0]; + if (typeof first !== 'string') return GC_TIMES.DEFAULT; + return PREFIX_GC_MAP[first] ?? GC_TIMES.DEFAULT; +} // ───────────────────────────────────────────────────────────────────────────── -// PUBLIC named cache/GC time buckets — used by hooks that prefer explicit -// per-query overrides instead of the automatic prefix→tier routing below. -// -// CACHE_TIMES = staleTime tiers. Picked by feature, not by query-key prefix. -// GC_TIMES = gcTime tiers. Mostly mirror staleTime, but allow keeping data -// around longer than it is "fresh" so back-navigation stays snappy. +// Per-domain query option presets // -// These exports are kept stable even when the internal STALE_* constants -// are tuned — consumers reference them by name. Added 2026-06-02 because -// useExternalCategoriesQuery (and likely future hooks) need named tiers. +// Convenient bundles for hooks that want to spread a known-good config: +// useQuery({ queryKey, queryFn, ...PRODUTOS_QUERY_OPTIONS }) // ───────────────────────────────────────────────────────────────────────────── -export const CACHE_TIMES = { - /** Stable reference data — categories, suppliers, materials, techniques */ - STABLE: STALE_STATIC, - /** Semi-static — product catalog, taxonomy lookups */ - SEMI: STALE_SEMI, - /** Live — quotes, notifications */ - LIVE: STALE_LIVE, - /** Real-time — connection status, health checks */ - REALTIME: STALE_REALTIME, +export const PRODUTOS_QUERY_OPTIONS = { + staleTime: CACHE_TIMES.PRODUTOS, + gcTime: GC_TIMES.DEFAULT, + refetchOnWindowFocus: false, + refetchOnMount: false, } as const; -export const GC_TIMES = { - /** Default GC window — most queries */ - DEFAULT: GC_DEFAULT, - /** Long retention for reference data that's expensive to refetch */ - LONG: GC_LONG, - /** Categorias técnicas — keep cached across navigations */ - TECNICAS: GC_LONG, +export const TECNICAS_QUERY_OPTIONS = { + staleTime: CACHE_TIMES.TECNICAS, + gcTime: GC_TIMES.TECNICAS, + refetchOnWindowFocus: false, + refetchOnMount: false, } as const; -// ───────────────────────────────────────────────────────────────────────────── -// Query-key prefix → stale-time routing -// ───────────────────────────────────────────────────────────────────────────── -type StaleTimeTier = 'static' | 'semi' | 'live' | 'realtime' | 'default'; - -const prefixToTier: Record = { - // Static reference data - categories: 'static', - suppliers: 'static', - materials: 'static', - techniques: 'static', - roles: 'static', - 'price-tables': 'static', - - // Operational data that changes on user action - products: 'semi', - 'catalog-products': 'semi', - 'sparkline-supplier-batch': 'semi', - - // Frequently refreshed - quotes: 'live', - notifications: 'live', - 'workspace-notifications': 'live', - 'quote-history': 'live', - - // Near real-time - 'connection-status': 'realtime', - 'bridge-health': 'realtime', -}; +export const TABELAS_PRECO_QUERY_OPTIONS = { + staleTime: CACHE_TIMES.TABELAS_PRECO, + gcTime: GC_TIMES.TECNICAS, + refetchOnWindowFocus: false, + refetchOnMount: false, +} as const; -function resolveStaleTime(queryKey: readonly unknown[]): number { - if (!Array.isArray(queryKey) || queryKey.length === 0) return STALE_DEFAULT; - const prefix = String(queryKey[0]); - const tier = prefixToTier[prefix] ?? 'default'; - switch (tier) { - case 'static': return STALE_STATIC; - case 'semi': return STALE_SEMI; - case 'live': return STALE_LIVE; - case 'realtime': return STALE_REALTIME; - default: return STALE_DEFAULT; - } -} +export const STABLE_DATA_QUERY_OPTIONS = { + staleTime: CACHE_TIMES.STABLE, + gcTime: GC_TIMES.LONG, + refetchOnWindowFocus: false, + refetchOnMount: false, +} as const; // ───────────────────────────────────────────────────────────────────────────── -// Default query options +// Default query options + QueryClient factory // ───────────────────────────────────────────────────────────────────────────── export const defaultQueryOptions: DefaultOptions = { queries: { - // staleTime is resolved per-key at runtime (see createQueryClient) - gcTime: GC_DEFAULT, + // staleTime / gcTime are resolved per-key at runtime (see createQueryClient) + gcTime: GC_TIMES.DEFAULT, retry: (failureCount, error) => { // Never retry on auth errors or 404s if (error && typeof error === 'object' && 'status' in error) { @@ -125,9 +208,6 @@ export const defaultQueryOptions: DefaultOptions = { }, }; -// ───────────────────────────────────────────────────────────────────────────── -// QueryClient factory -// ───────────────────────────────────────────────────────────────────────────── export function createQueryClient(): QueryClient { const config: QueryClientConfig = { defaultOptions: defaultQueryOptions, @@ -135,19 +215,21 @@ export function createQueryClient(): QueryClient { const client = new QueryClient(config); - // Override staleTime per query-key prefix using a queryCache observer. - // This runs once per query creation — cheap, deterministic. + // Override staleTime/gcTime per query-key prefix using a queryCache observer. + // Runs once per query creation — cheap, deterministic, no per-render cost. client.getQueryCache().subscribe((event) => { if (event.type === 'added' || event.type === 'updated') { const query = event.query; if (query.options.staleTime === undefined) { - query.options.staleTime = resolveStaleTime(query.queryKey); + query.options.staleTime = getStaleTimeForKey(query.queryKey); + } + if (query.options.gcTime === undefined) { + query.options.gcTime = getGcTimeForKey(query.queryKey); } } }); // Expose to window for edge-case prefetching (e.g. hover on cards) — dev only. - // Window's specific shape doesn't overlap with an index signature, so widen via unknown. if (import.meta.env.DEV && typeof window !== 'undefined') { (window as unknown as Record).queryClient = client; }