76 lines
3.1 KiB
Markdown
76 lines
3.1 KiB
Markdown
# 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)
|
|
``` |