# F-063 — Implementer evidence ## Scope delivered The product editor in `apps/admin/src/features/products/components/ProductEditor.tsx` called `productsApi.get(productId)` on every render because the load effect had `getSnap` (a `useCallback` rebuilt whenever any of the form state changes) in its dependency list. Combined with `setCategoryIds(p.categoryIds ?? [])` producing a fresh array reference on every load, React never bailed out, the effect re-fired, and the page settled into an infinite fetch loop. The visible symptom was a hanging `GET /api/products/:id` and the inability to interact with the `featured` toggle, the category list, and the attribute grid — every click was immediately overwritten by the next fetch. ## Change `project/apps/admin/src/features/products/components/ProductEditor.tsx` - Removed `getSnap` from the load effect's dependency list. The effect now runs once per `productId` (a one-shot mount), with an explicit `eslint-disable-next-line react-hooks/exhaustive-deps` and a `cancelled` flag to drop late responses from a stale request. - Introduced a `getSnapRef` that is updated each render. The load handler now uses `getSnapRef.current()` to compute the initial snapshot, so the load effect no longer depends on a function that changes with form state. - The dirty-check effect keeps `getSnap` in its deps — that one is intentionally re-evaluated on every form change and does not cause a re-fetch. ```ts const getSnapRef = useRef(getSnap); useEffect(() => { getSnapRef.current = getSnap; }, [getSnap]); useEffect(() => { if (!productId) { setLoading(false); return; } let cancelled = false; productsApi.get(productId).then((p: Product) => { if (cancelled) return; setName(p.name); setSlug(p.slug); setDesc(p.description ?? ''); // … snapRef.current = getSnapRef.current(); setLoading(false); }).catch(() => { if (cancelled) return; setError('No se pudo cargar el producto'); setLoading(false); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId]); ``` ## Acceptance traceability | Acceptance criterion | How it is met | | -------------------- | ------------- | | `GET /api/products/:id` fires exactly once on mount and again only on navigation | The load effect depends only on `productId`; `cancelled` guard drops stale responses. | | Clicking `featured` and a category updates state and persists after re-render | With the loop gone, `setFeatured` / `setCategoryIds` actually apply and stick. | | Loading spinner disappears after the first successful fetch and never reappears | `setLoading(false)` only runs inside the load effect, which no longer re-fires. | | `verify.sh` is green | Exit 0. | ## Manual verification - `npm run typecheck` (admin) — exit 0 - `npx tsc --noEmit` (admin) — exit 0 - `./scripts/verify.sh` — exit 0 - Diff is limited to the dependency list and the `cancelled` / `getSnapRef` plumbing. The data-loading code path is otherwise unchanged: same endpoints, same payload mapping. ## Files touched ``` project/apps/admin/src/features/products/components/ProductEditor.tsx (modified) ```