-
Notifications
You must be signed in to change notification settings - Fork 0
fix: restore ProductsContext API + add missing query-config exports (unblocks production deploy) #604
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: restore ProductsContext API + add missing query-config exports (unblocks production deploy) #604
Changes from all commits
17cec9a
4fe64f2
9536c79
b8c1860
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,90 +24,222 @@ 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; | ||||||
| /** Resolves a single id (returns undefined if not cached; does NOT trigger fetch) */ | ||||||
| getProductById: (id: string) => Product | undefined; | ||||||
|
Comment on lines
+27
to
+28
|
||||||
| /** | ||||||
| * Batch lookup — returns cached products matching the given ids (in any order). | ||||||
| * 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[]; | ||||||
|
Comment on lines
+27
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes—side effects like starting timers or network requests should be treated as “side effects” and must not run during render in React 18. React’s docs explicitly require that components and hooks be pure during render and that “side effects should not run in render” because React may render components multiple times for the best user experience [1][2]. Can concurrent rendering or StrictMode restart/discard renders after those side effects already ran? - Concurrent rendering: React can pause rendering, continue later, and even abandon an in-progress render. React guarantees UI consistency by delaying DOM mutations until commit, which implies that anything you did during render (like scheduling a timer or firing a request) would not be part of the “guaranteed” commit behavior, and you must not do it anyway [3]. - StrictMode (development): React intentionally double-invokes certain component functions in development to help you find impure code; StrictMode “calls some of your functions … twice in development” including the component function body (top-level render logic) [4]. React also double-invokes effects in development, and the React team notes that an effect firing twice in StrictMode development can lead to issues like double fetching, but it’s intended to surface bugs; they recommend making effects safe via cleanup/ignores (double fetch has no harm if you handle it correctly) [5][6]. Practical conclusion: - Do not start timers or initiate network requests in render (including top-level component body). Put them in event handlers or in Effects (useEffect/useLayoutEffect) where side effects are allowed [1]. - If you fetch in an effect, assume development/StrictMode may run it more than once; write the effect to be resilient (e.g., abort/cancel, or ignore stale responses, and clean up) [6][5]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/contexts/ProductsContext.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
echo "=== file: $FILE (head) ==="
sed -n '1,120p' "$FILE" | nl -ba
echo "=== file: $FILE (getters + batch scheduling) ==="
sed -n '120,240p' "$FILE" | nl -ba
echo "=== file: $FILE (tail) ==="
wc -l "$FILE"
tail -n 80 "$FILE" | nl -baRepository: adm01-debug/promo-gifts-v4 Length of output: 186 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="src/contexts/ProductsContext.tsx"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
echo "=== lines 1-220 (with numbers) ==="
awk '{printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n '1,220p'
echo "=== total lines ==="
wc -l "$FILE"Repository: adm01-debug/promo-gifts-v4 Length of output: 8036 Evitar side effects em getters e corrigir estado/contrato do cache em
🤖 Prompt for AI Agents |
||||||
| /** 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); | ||||||
|
Comment on lines
+62
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Cada batch seta 💡 Sugestão de ajuste const cacheRef = useRef<Map<string, Product>>(cache);
const fetchingRef = useRef<Set<string>>(new Set());
+ const inFlightBatchesRef = useRef(0);
const batchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchIdsRef = useRef<Set<string>>(new Set());
const mountedRef = useRef(true);
@@
idsToFetch.forEach((id) => fetchingRef.current.add(id));
+ inFlightBatchesRef.current += 1;
if (mountedRef.current) setIsLoading(true);
@@
} finally {
idsToFetch.forEach((id) => fetchingRef.current.delete(id));
- if (mountedRef.current) setIsLoading(false);
+ inFlightBatchesRef.current -= 1;
+ if (mountedRef.current) {
+ setIsLoading(inFlightBatchesRef.current > 0);
+ }
}Also applies to: 94-115 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| 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)); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Missing negative-cache guard causes infinite refetch loop for non-existent product IDs. When a requested ID isn't returned by Prompt for AI agents |
||||||
| if (mountedRef.current) setIsLoading(false); | ||||||
|
Comment on lines
+114
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a saved favorite/compare/recently-viewed item references a product that is inactive, deleted, or otherwise not returned by Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents |
||||||
| } | ||||||
| }, 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 invalidateCache = useCallback(() => { | ||||||
| fetchedIdsRef.current.clear(); | ||||||
| pendingRef.current.clear(); | ||||||
| setProducts([]); | ||||||
| setError(null); | ||||||
| const getProductById = useCallback( | ||||||
| (id: string): Product | undefined => { | ||||||
| const cached = cacheRef.current.get(id); | ||||||
| if (!cached) { | ||||||
| queueFetch([id]); | ||||||
| } | ||||||
| return cached; | ||||||
| }, | ||||||
| [queueFetch], | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Suggested change
|
||||||
| ); | ||||||
|
|
||||||
| 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], | ||||||
|
Comment on lines
+164
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a valid but cold product id, the first Useful? React with 👍 / 👎. |
||||||
| ); | ||||||
|
|
||||||
| // 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; | ||||||
| }); | ||||||
| }, []); | ||||||
|
Comment on lines
+169
to
183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Hoje ele só insere quando 💡 Sugestão de ajuste 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)) {
+ if (next.get(p.id) !== p) {
next.set(p.id, p);
changed = true;
}
}
return changed ? next : prev;
});
}, []);🤖 Prompt for AI Agents |
||||||
|
|
||||||
| // Memoize the products array from cache | ||||||
| const products = useMemo(() => [...cache.values()], [cache]); | ||||||
|
|
||||||
| const value = useMemo( | ||||||
| () => ({ products, isLoading, error, fetchProducts, getProduct, invalidateCache }), | ||||||
| [products, isLoading, error, fetchProducts, getProduct, invalidateCache], | ||||||
| () => ({ products, isLoading, getProductById, getProductsByIds, registerProducts }), | ||||||
| [products, isLoading, getProductById, getProductsByIds, registerProducts], | ||||||
| ); | ||||||
|
|
||||||
| return <ProductsContext.Provider value={value}>{children}</ProductsContext.Provider>; | ||||||
| return ( | ||||||
| <ProductsContext.Provider key={key} 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: () => {}, | ||||||
| }; | ||||||
|
|
||||||
| /** | ||||||
| * 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; | ||||||
| } | ||||||
|
|
||||||
| export function useProducts() { | ||||||
| const ctx = useContext(ProductsContext); | ||||||
| if (!ctx) throw new Error('useProducts must be used within ProductsProvider'); | ||||||
| return ctx; | ||||||
| /** | ||||||
| * 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) and that prefer to | ||||||
| * branch on null themselves rather than rely on the no-op fallback. | ||||||
| * | ||||||
| * Example: | ||||||
| * const ctx = useProductsContextSafe(); | ||||||
| * const data = ctx?.getProductsByIds(ids) ?? []; | ||||||
| */ | ||||||
| export function useProductsContextSafe(): ProductsContextType | null { | ||||||
| return useContext(ProductsContext) ?? null; | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3:
getProductByIddocumentation is incorrect: it says no fetch is triggered, but the function queues a fetch on cache miss.Prompt for AI agents