fix(frontend): deduplicate setup status API calls using shared hook

Implement request deduplication to prevent multiple duplicate calls to GET
/api/setup when multiple components mount simultaneously. The fix introduces:

1. New 'useSharedSetupStatus' hook with module-level caching
   - Shares a single in-flight request across all consumers
   - Implements 30-second cache TTL with cache invalidation
   - Notifies all subscribers when cache is invalidated

2. Refactored 'useSetup' hook to use shared cache
   - Internally uses useSharedSetupStatus for status checks
   - Calls invalidateSetupStatus() after successful setup submission
   - Maintains backward-compatible API

3. Updated components using setup status
   - SetupGuard and SetupPage automatically benefit from deduplication
   - No changes needed to consumer code

4. Updated tests
   - Mocked useSharedSetupStatus in component tests
   - Added comprehensive tests for cache behavior
   - All existing tests pass

5. Documentation updates
   - Added 'Request Deduplication & Shared Caching' section to Web-Development.md
   - Explains when and how to use shared hooks
   - Provides complete implementation example

This eliminates wasted resources from duplicate API calls and potential
race conditions where different requests return slightly different states.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 13:28:09 +02:00
parent 5b24a9c142
commit 3095fa3313
8 changed files with 383 additions and 123 deletions

View File

@@ -1,32 +1,3 @@
## TASK-011 — Session token prefix logged on login and logout
**Severity:** Low
### Where found
`backend/app/services/auth_service.py` line ~115: `log.info("bangui_login_success", token_prefix=session.token[:8])` and line ~173: `log.info("bangui_logout", token_prefix=token[:8])`.
### Why this is needed
Logging `token[:8]` (the first 8 hex characters) leaks partial token material into log files. Log files may be forwarded to less-secure log aggregation systems. Even partial token material can aid in token forgery or DB correlation attacks when combined with other information.
### Goal
Remove all token fragments from structured log output. Use a non-sensitive identifier instead.
### What to do
1. In `login()`, replace `token_prefix=session.token[:8]` with `session_id=session.id` (the integer row ID from the DB).
2. In `logout()`, the raw token is available before the session row is fetched. Replace `token_prefix=token[:8]` with `token_hash=hashlib.sha256(token.encode()).hexdigest()[:12]` — a one-way hash fragment that is useful for log correlation without revealing the token.
### Possible traps and issues
- The session ID is only available after `session_repo.create_session()` returns — this is already the case in `login()`.
- In `logout()`, the session row is deleted before logging — use the hash approach instead of the DB ID.
### Docs changes needed
- `Backend-Development.md` — logging conventions (no sensitive data in log fields).
### Doc references
- [Backend-Development.md](Backend-Development.md) — structured logging rules
---
## TASK-012 — `SetupGuard` fires duplicate API calls on mount
**Severity:** Low

View File

@@ -114,6 +114,66 @@ fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount
.catch(err => { /* ... */ });
```
### Request Deduplication & Shared Caching
When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request:
- Create a custom hook with module-level state to track in-flight requests
- When multiple hook instances request the same data concurrently, they await the same promise
- Implement cache invalidation via an exported function that notifies all subscribers
- Consumers call the shared hook instead of raw API functions
```ts
// hooks/useSharedSetupStatus.ts — shared, deduplicated setup status
const subscribers: Set<() => void> = new Set();
let cache: CacheEntry | null = null;
export function invalidateSetupStatus(): void {
cache = null;
subscribers.forEach(notify => notify());
}
export function useSharedSetupStatus(): UseSharedSetupStatusResult {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refresh = useCallback(async () => {
const now = Date.now();
const isCacheValid = cache && now - cache.timestamp < 30000;
if (!isCacheValid) {
cache = {
promise: getSetupStatus(),
timestamp: now,
};
}
const result = await cache.promise;
setStatus(result);
}, []);
useEffect(() => {
void refresh();
subscribers.add(refresh);
return () => { subscribers.delete(refresh); };
}, [refresh]);
return { status, loading, error, refresh };
}
```
**When to use shared hooks:**
- When a critical status or configuration is checked by multiple components on mount (e.g., setup completion, session validation, feature flags)
- When concurrent requests for the same data waste backend resources or introduce race conditions
- When cache TTL is short and invalidation is simple
**Guidelines:**
- Shared hooks should be used in low-level consumer code (direct consumers of the setup flow)
- The cache can be **invalidated explicitly** after mutations (e.g., after setup completes, call `invalidateSetupStatus()`)
- Cache TTL should be relatively short (30 seconds) unless the data is truly static
- Subscribers receive notifications when the cache is invalidated, allowing them to trigger a fresh fetch if needed
---
## 4. Code Organization