Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 181 additions & 71 deletions src/contexts/ProductsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<symbol, unknown>;
const isDuplicateModule = !!globalObj[INSTANCE_KEY];
const isDuplicateModule = globalObj[INSTANCE_KEY] && globalObj[INSTANCE_KEY] !== Math.random();
globalObj[INSTANCE_KEY] = globalObj[INSTANCE_KEY] || Math.random();
Comment on lines 18 to 21

interface ProductsContextType {
/** Cached products (only those that have been requested) */
products: Product[];
isLoading: boolean;
error: string | null;
fetchProducts: (ids: string[]) => Promise<void>;
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<ProductsContextType | null>(null);
export const ProductsContext = createContext<ProductsContextType | undefined>(undefined);

interface ProductsProviderProps {
children: ReactNode;
}

export function ProductsProvider({ children }: ProductsProviderProps) {
const [products, setProducts] = useState<Product[]>([]);
/**
* 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<Map<string, Product>>(new Map());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchedIdsRef = useRef<Set<string>>(new Set());
const pendingRef = useRef<Set<string>>(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<Map<string, Product>>(cache);
const fetchingRef = useRef<Set<string>>(new Set());
const batchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchIdsRef = useRef<Set<string>>(new Set());
const mountedRef = useRef(true);

useEffect(() => {
cacheRef.current = cache;
}, [cache]);
Comment on lines +61 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the cache ref in sync before child render

Consumers such as useEnrichedFavoriteItems and ComparePage call getProductsByIds during render and depend on the products signal to rerun after a fetch. After setCache commits, children render before this passive effect updates cacheRef, so they read the old cache, return no products, and queue the same IDs again; the fetched products only appear after another loading/timer render and incur a duplicate request. Update cacheRef in the cache setter (or otherwise before children render) so the render triggered by products can see the new entries immediately.

Useful? React with 👍 / 👎.


// 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(() => {
Comment on lines +77 to +78
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;
Comment on lines +99 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop refetching IDs that return no product

When a saved favorite/compare/recently-viewed item points at a deleted or inactive product, the ID is requested but fetchPromobrindProducts returns no row, so nothing is cached here. The finally block then removes the ID from fetchingRef, and the next render sees it missing and schedules the same request again; because setCache still returns a new Map for an empty result, this can turn a single missing product into a continuous render/network loop. Track not-found IDs or avoid updating state when no rows are returned.

Useful? React with 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make lookup callbacks invalidate memoized consumers

Because getProductsByIds is memoized only on queueFetch and reads the cache through a ref, its identity does not change when the async fetch fills the cache. Consumers such as RecentlyViewedBar, RecentlyViewedPopover, and the trash views memoize their resolved products with deps like [items, getProductsByIds], so they queue the first fetch but never recompute after the provider receives the products. Include a cache version/cache dependency in the lookup API or require the provider value used by these callbacks to change with cache contents.

Useful? React with 👍 / 👎.

(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;
Comment on lines +170 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update registered products instead of ignoring them

If a product was first lazy-loaded by favorites/compare and later the catalog query returns a fresher version, registerProducts skips it because the ID already exists. Context consumers that rely on the shared cache for price-drop calculations, seller-cart stock, or images will keep showing the stale lazy-loaded record until a full reload even though the page-level query has newer data. Registration should replace existing entries when the incoming product differs.

Useful? React with 👍 / 👎.

}
}
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 (
<ProductsContext.Provider
key={key}
value={{ products, isLoading, getProductById, getProductsByIds, registerProducts }}
>
{children}
</ProductsContext.Provider>
);
}

return <ProductsContext.Provider value={value}>{children}</ProductsContext.Provider>;
/**
* 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;
}
55 changes: 55 additions & 0 deletions src/lib/query-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point product prefetches at the detail query key

When ProductCard prefetches on hover, usePrefetchProduct uses this prefix to cache productService.fetchProductById(productId), but the detail page's useProduct reads ['promobrind-product', id]. With the new 'products' prefix, the hover prefetch is stored under a key no detail consumer uses, so navigating to a hovered product still performs the full detail fetch instead of reusing the warmed cache.

Useful? React with 👍 / 👎.

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
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading