feat(F-063): completed feature
This commit is contained in:
76
work/artifacts/F-063/implementer.md
Normal file
76
work/artifacts/F-063/implementer.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 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)
|
||||
```
|
||||
13
work/artifacts/F-063/leader-close.json
Normal file
13
work/artifacts/F-063/leader-close.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"feature_id": "F-063",
|
||||
"agent": "leader",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "All gates approved. Closing F-063.",
|
||||
"evidence": [
|
||||
"work/artifacts/F-063/reviewer.json verdict=APPROVED",
|
||||
"work/artifacts/F-063/security.json verdict=APPROVED",
|
||||
"work/artifacts/F-063/qa.json verdict=APPROVED",
|
||||
"./scripts/verify.sh exit 0"
|
||||
],
|
||||
"timestamp": "2026-08-19T15:10:00Z"
|
||||
}
|
||||
14
work/artifacts/F-063/qa.json
Normal file
14
work/artifacts/F-063/qa.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"feature_id": "F-063",
|
||||
"agent": "qa",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "The root cause of both the hang and the unselectable controls was the same infinite fetch loop. With the load effect pinned to [productId], one GET fires on mount; subsequent state updates no longer trigger a re-fetch, so the featured checkbox and the category list respond to clicks as expected.",
|
||||
"evidence": [
|
||||
"AC1 'GET /api/products/:id fires exactly once on mount and again only on navigation' — load effect now has [productId] only",
|
||||
"AC2 'Clicking featured and a category updates state and persists after re-render' — no more refetch overwriting the selection",
|
||||
"AC3 'Loading spinner disappears after the first successful fetch and never reappears' — setLoading(false) runs only inside the now-one-shot effect",
|
||||
"AC4 'verify.sh is green' — exit 0",
|
||||
"Regression: typecheck green, build green, no API change"
|
||||
],
|
||||
"timestamp": "2026-08-19T15:10:00Z"
|
||||
}
|
||||
14
work/artifacts/F-063/reviewer.json
Normal file
14
work/artifacts/F-063/reviewer.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"feature_id": "F-063",
|
||||
"agent": "reviewer",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "Minimal, surgical fix. The load effect was the only place where getSnap was used as a dep while also calling setState — exactly the shape that causes an infinite render loop. Moving getSnap into a ref keeps the same snapshot semantics but decouples the load effect from the form state. cancelled guard handles the unmount-during-fetch race. No API change, no payload change.",
|
||||
"evidence": [
|
||||
"git diff project/apps/admin/src/features/products/components/ProductEditor.tsx — only the load effect deps changed; data-mapping code is untouched",
|
||||
"Load effect now has [productId] only (with eslint-disable for exhaustive-deps and a cancelled flag)",
|
||||
"getSnapRef introduced to capture the latest snapshot getter without depending on its identity",
|
||||
"npx tsc --noEmit (admin) — exit 0",
|
||||
"./scripts/verify.sh — exit 0"
|
||||
],
|
||||
"timestamp": "2026-08-19T15:10:00Z"
|
||||
}
|
||||
12
work/artifacts/F-063/security.json
Normal file
12
work/artifacts/F-063/security.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"feature_id": "F-063",
|
||||
"agent": "security",
|
||||
"verdict": "APPROVED",
|
||||
"summary": "No security boundary touched. The fix only restructures React effects; same data is loaded from the same authenticated endpoint. The cancelled flag avoids acting on a stale response after unmount, which is a small improvement.",
|
||||
"evidence": [
|
||||
"Endpoint, headers, and payload unchanged — GET /api/products/:id via the existing api-client",
|
||||
"cancelled flag prevents a race where a slow response arrives after navigation away",
|
||||
"No new env vars, no new dependencies"
|
||||
],
|
||||
"timestamp": "2026-08-19T15:10:00Z"
|
||||
}
|
||||
Reference in New Issue
Block a user