Skip to content

feat: PWA hook typing, browser share adapter, camera stream hook, IndexedDB repository#532

Merged
OlufunbiIK merged 1 commit into
OlufunbiIK:mainfrom
playground-ogazboiz:feat/issues-487-488-489-490
Apr 28, 2026
Merged

feat: PWA hook typing, browser share adapter, camera stream hook, IndexedDB repository#532
OlufunbiIK merged 1 commit into
OlufunbiIK:mainfrom
playground-ogazboiz:feat/issues-487-488-489-490

Conversation

@ogazboiz
Copy link
Copy Markdown
Contributor

@ogazboiz ogazboiz commented Apr 28, 2026

Summary

  • PWA Install Prompt Hook Typing and Cleanup #487src/types/pwa.ts: BeforeInstallPromptEvent, InstallOutcome, PWAHook types. src/hooks/usePWA.ts: replaced any with typed BeforeInstallPromptEvent stored in a ref, added stable named handlers for all four window events (online, offline, beforeinstallprompt, appinstalled) with full cleanup on unmount, exposed canInstall: boolean instead of the raw event, installApp() returns InstallOutcome | null. InstallPrompt.tsx updated to canInstall.

  • Clipboard and Share Capability Adapter #488src/utils/browserShare.ts: canNativeShare(), canWriteClipboard() capability detectors; shareOrCopy(data) tries native share → clipboard fallback → unsupported with typed ShareOutcome; copyToClipboard(text) for clipboard-only flows. Updated ShareModal.tsx, QRCodeGenerator.tsx, and SplitCalculator.tsx to use the adapter — no more inline navigator.share / navigator.clipboard branching.

  • QR Scanner and Camera Stream Unification #489src/hooks/useCameraStream.ts: shared hook owning requestCameraPermission, stopCameraStream, stream ref, status machine (idle → requesting → active → error → stopped), startStream(), stopStream(), restartStream(), and cleanup on unmount via mountedRef. QRCodeScanner and CameraCapture can consume this hook to eliminate duplicate stream management.

  • IndexedDB Offline Repository Hardening #490src/types/offline.ts: OfflineSplit, QueuedPayment, store name constants, DB_VERSION = 2. src/utils/offlineRepository.ts: splitRepository (save, get, getAll, getPendingSync, markSynced, delete, clear) and queuedPaymentRepository (enqueue, get, getAll, recordAttempt, dequeue, clear) — all fully typed, no any. Versioned upgrade handler migrates from v1 schema.

Test plan

  • usePWA — no any in state, beforeinstallprompt listener removed on unmount, canInstall flips to false after installApp() resolves
  • shareOrCopy — native share available → calls navigator.share; share rejected → falls through to clipboard; clipboard only → writes text
  • copyToClipboard — returns { success: false, error } when clipboard API unavailable
  • useCameraStreamstartStream sets status active; stopStream releases tracks; cleanup stops stream on unmount
  • splitRepository.getPendingSync — returns only records with pendingSync: true
  • queuedPaymentRepository.recordAttempt — increments attempts and sets lastError

Closes #487
Closes #488
Closes #489
Closes #490

Summary by CodeRabbit

Release Notes

  • New Features

    • Added offline support for saving splits and payments to sync when connectivity returns
    • Improved camera access with enhanced lifecycle management
  • Bug Fixes

    • Enhanced copy-to-clipboard and share functionality with better error handling and fallback support
  • Refactor

    • Consolidated share and copy behaviors across components to centralized utilities
    • Improved PWA installation prompt handling with better state management

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

@ogazboiz is attempting to deploy a commit to the olufunbiik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

📝 Walkthrough

Walkthrough

This PR addresses four linked infrastructure improvements: typing and cleanup of the PWA install hook with new typed interfaces, introduction of a centralized browser share/clipboard utility to replace inline API usage, creation of a reusable camera stream hook for unified media lifecycle management, and new typed offline storage infrastructure for splits and queued payments using IndexedDB with typed repositories.

Changes

Cohort / File(s) Summary
PWA Hook & Install Prompt Improvements
frontend/src/hooks/usePWA.ts, frontend/src/components/InstallPrompt.tsx, frontend/src/types/pwa.ts
Rewrote usePWA to remove any-typed installPrompt, store prompt in typed promptRef, expose boolean canInstall, return typed InstallOutcome, and register/cleanup window listeners. Updated InstallPrompt to use canInstall flag instead of installPrompt value.
Browser Share/Clipboard Adapter
frontend/src/utils/browserShare.ts, frontend/src/components/Payment/QRCodeGenerator.tsx, frontend/src/components/Split/ShareModal.tsx, frontend/src/components/SplitCalculator/SplitCalculator.tsx
Introduced browserShare utility with capability detection (canNativeShare, canWriteClipboard), typed outcome handling, and two functions: copyToClipboard (returns success/error result) and shareOrCopy (prefers native share with clipboard fallback). Updated all three components to delegate copy/share logic to these adapters, removing inline error handling.
Camera Stream Hook
frontend/src/hooks/useCameraStream.ts
Added new hook managing camera MediaStream lifecycle with typed status states (idle, requesting, active, error, stopped), error object, and methods startStream, stopStream, restartStream. Includes auto-start option, cleanup on unmount, and permission integration via requestCameraPermission.
Offline Storage Infrastructure
frontend/src/types/offline.ts, frontend/src/utils/offlineRepository.ts
Created typed definitions for offline data (OfflineSplit, QueuedPayment, store names, constants). Introduced offlineRepository with lazily-initialized IndexedDB singleton using schema v2, exports two typed repositories (splitRepository, queuedPaymentRepository) with methods for persistence, retrieval, sync status tracking, and queue retry attempts.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 Hops of joy through typed hooks so clean,
PWA prompts with types unseen,
Camera streams and shares aligned,
Offline persistence—none left behind!
IndexedDB dreams come true,
With adapters fresh and types brand new.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the four main features added: PWA hook typing, browser share adapter, camera stream hook, and IndexedDB repository.
Linked Issues check ✅ Passed All four linked issues (#487-#490) are addressed with appropriate implementations meeting their acceptance criteria.
Out of Scope Changes check ✅ Passed All changes are scoped to the four linked issues with no extraneous modifications outside the defined objectives.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
frontend/src/hooks/useCameraStream.ts (1)

51-54: Hoist the default constraints object.

When callers omit constraints, this object literal is recreated on every render, so useCallback([constraints]) gives startStream/restartStream new identities even when nothing changed. That can churn downstream effects and memoized consumers.

♻️ Suggested fix
+const DEFAULT_CAMERA_CONSTRAINTS: MediaStreamConstraints = {
+  video: { facingMode: 'environment' },
+  audio: false,
+};
+
 export function useCameraStream({
-  constraints = { video: { facingMode: 'environment' }, audio: false },
+  constraints = DEFAULT_CAMERA_CONSTRAINTS,
   autoStart = false,
 }: UseCameraStreamOptions = {}): UseCameraStreamReturn {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useCameraStream.ts` around lines 51 - 54, The default
constraints object is recreated on every render causing useCallback hooks like
startStream and restartStream (which depend on constraints) to get new
identities; hoist the literal by declaring a top-level constant (e.g.,
DEFAULT_CONSTRAINTS) and use that as the default parameter in useCameraStream
instead of the inline object, then ensure references to constraints in
startStream/restartStream dependency arrays still work with the stable default
so those callbacks stop changing identity when callers omit constraints.
frontend/src/components/Split/ShareModal.tsx (1)

17-27: Handle adapter outcomes to provide user feedback.

The handlers at lines 18 and 22 ignore return values from copyToClipboard and shareOrCopy. Both functions return explicit success/failure metadata ({ success: true/false; error?: Error } and { method, success } respectively), but these outcomes are not inspected. Copy and share failures silently fail without user feedback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Split/ShareModal.tsx` around lines 17 - 27, Update
handleCopy and handleShare to inspect the adapter return values from
copyToClipboard and shareOrCopy and surface success/failure to the user: call
copyToClipboard(splitLink) and check its {success, error} result and display a
success or error message (e.g., trigger existing toast/notification or set local
error state) when success is false; similarly call shareOrCopy(...) and inspect
its {method, success} result and notify the user if sharing failed (or confirm
which method succeeded). Modify the handlers (handleCopy, handleShare) to await
the result, branch on the returned success flag, and use the component’s
existing UI feedback mechanism to inform the user of success or show the error
details from the adapter.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/components/Payment/QRCodeGenerator.tsx`:
- Around line 50-59: The try block in handleCopyUri is missing a catch/finally,
causing a syntax error; wrap the await copyToClipboard(paymentUri) call inside
try { ... } and add a catch (err) { setError(err?.message || String(err)); } (or
a finally) so errors are handled and the block is syntactically correct;
reference the handleCopyUri function, the copyToClipboard call, and the state
setters setCopied and setError when adding the catch/finally.

In `@frontend/src/hooks/useCameraStream.ts`:
- Around line 90-95: The catch in useCameraStream currently hardcodes setError({
type: 'unknown', message }), losing structured camera error details; update the
handler to preserve the original camera error type when available (e.g., read
(err as CameraError).type or a cameraErrorType helper) and pass that into
setError instead of 'unknown', while still using
getUserFriendlyErrorMessage(err) for message and guarding with
mountedRef.current before calling setStatus and setError.
- Around line 61-102: Pending camera requests can resolve after
stopStream/unmount and reassign streamRef or setStatus to 'active'; to fix, add
a cancellable request token (e.g., requestIdRef or a local closure id inside
startStream) and check it before mutating state or streamRef in startStream's
success/error paths, and increment/clear that token inside stopStream and the
unmount cleanup to invalidate outstanding requests; use the existing identifiers
requestCameraPermission, startStream, stopStream, streamRef, and mountedRef when
implementing the token check so late-resolving promises do nothing.

In `@frontend/src/hooks/usePWA.ts`:
- Around line 29-32: The install flow can race with the appinstalled handler
which clears promptRef.current; update installApp to copy promptRef.current into
a local variable (e.g., savedPrompt = promptRef.current) before calling await
savedPrompt.prompt() and use that savedPrompt for subsequent checks and
showPrompt calls to avoid null dereference; do the same for the second
occurrence where promptRef.current is used after an await, and leave
handleAppInstalled as-is (it should still clear promptRef.current).

In `@frontend/src/utils/offlineRepository.ts`:
- Around line 75-80: markSynced and recordAttempt perform separate read and
write awaits which can race and clobber concurrent updates; change both to
perform get+modify+put inside a single readwrite transaction (use getDb(), start
a transaction on STORES.splits, call tx.store.get(id), update the record and
tx.store.put(updated) before committing) so the read/modify/write is atomic, and
update recordAttempt to only clear lastError when an explicit null/empty
indicator is provided (otherwise preserve existing lastError) while incrementing
the attempt counter using the value read inside the same transaction; reference
functions: markSynced, recordAttempt, getDb, STORES.splits, and OfflineSplit.

---

Nitpick comments:
In `@frontend/src/components/Split/ShareModal.tsx`:
- Around line 17-27: Update handleCopy and handleShare to inspect the adapter
return values from copyToClipboard and shareOrCopy and surface success/failure
to the user: call copyToClipboard(splitLink) and check its {success, error}
result and display a success or error message (e.g., trigger existing
toast/notification or set local error state) when success is false; similarly
call shareOrCopy(...) and inspect its {method, success} result and notify the
user if sharing failed (or confirm which method succeeded). Modify the handlers
(handleCopy, handleShare) to await the result, branch on the returned success
flag, and use the component’s existing UI feedback mechanism to inform the user
of success or show the error details from the adapter.

In `@frontend/src/hooks/useCameraStream.ts`:
- Around line 51-54: The default constraints object is recreated on every render
causing useCallback hooks like startStream and restartStream (which depend on
constraints) to get new identities; hoist the literal by declaring a top-level
constant (e.g., DEFAULT_CONSTRAINTS) and use that as the default parameter in
useCameraStream instead of the inline object, then ensure references to
constraints in startStream/restartStream dependency arrays still work with the
stable default so those callbacks stop changing identity when callers omit
constraints.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 591515ac-a421-4447-af60-d02cdfb704bb

📥 Commits

Reviewing files that changed from the base of the PR and between 0d3cb02 and 0212093.

📒 Files selected for processing (10)
  • frontend/src/components/InstallPrompt.tsx
  • frontend/src/components/Payment/QRCodeGenerator.tsx
  • frontend/src/components/Split/ShareModal.tsx
  • frontend/src/components/SplitCalculator/SplitCalculator.tsx
  • frontend/src/hooks/useCameraStream.ts
  • frontend/src/hooks/usePWA.ts
  • frontend/src/types/offline.ts
  • frontend/src/types/pwa.ts
  • frontend/src/utils/browserShare.ts
  • frontend/src/utils/offlineRepository.ts

Comment on lines 50 to 59
try {
await navigator.clipboard.writeText(paymentUri);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (copyError) {
setError((copyError as Error).message);
}
// Issue #488 — use browserShare adapter
const result = await copyToClipboard(paymentUri);
if (result.success) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setError(result.error.message);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspecting handleCopyUri block:"
nl -ba frontend/src/components/Payment/QRCodeGenerator.tsx | sed -n '45,65p'

python - <<'PY'
from pathlib import Path
import re, sys

p = Path("frontend/src/components/Payment/QRCodeGenerator.tsx")
text = p.read_text(encoding="utf-8")
m = re.search(r'const handleCopyUri = async \(\) => \{([\s\S]*?)\n  \};', text)
if not m:
    print("FAIL: handleCopyUri block not found")
    sys.exit(1)

block = m.group(1)
has_try = "try {" in block
has_catch = "catch (" in block or "catch{" in block
has_finally = "finally {" in block or "finally{" in block

if has_try and not (has_catch or has_finally):
    print("FAIL: try without catch/finally detected in handleCopyUri")
    sys.exit(2)

print("PASS: no malformed try block detected in handleCopyUri")
PY

Repository: OlufunbiIK/StellarSplit

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Reading lines 45-65 from QRCodeGenerator.tsx ==="
if [ -f "frontend/src/components/Payment/QRCodeGenerator.tsx" ]; then
  sed -n '45,65p' "frontend/src/components/Payment/QRCodeGenerator.tsx" | cat -n
else
  echo "File not found"
  exit 1
fi

echo ""
echo "=== Checking try-catch structure in handleCopyUri ==="
python3 - <<'PY'
from pathlib import Path
import re, sys

p = Path("frontend/src/components/Payment/QRCodeGenerator.tsx")
if not p.exists():
    print("FAIL: File does not exist")
    sys.exit(1)

text = p.read_text(encoding="utf-8")

# Search for handleCopyUri function
m = re.search(r'const handleCopyUri = async \(\) => \{([\s\S]*?)\n  \};', text)
if not m:
    print("FAIL: handleCopyUri block not found with expected pattern")
    # Try alternative pattern
    m = re.search(r'const handleCopyUri[^}]*\{([\s\S]*?)\n\s*\};', text)
    if not m:
        print("Alternative pattern also failed")
        sys.exit(1)

block = m.group(0)
print(f"Found handleCopyUri block:\n{block[:200]}...")

# Check for try-catch-finally
has_try = "try {" in block or "try{" in block
has_catch = "catch" in block
has_finally = "finally" in block

print(f"\nhas_try: {has_try}, has_catch: {has_catch}, has_finally: {has_finally}")

if has_try and not (has_catch or has_finally):
    print("CRITICAL: try without catch/finally detected!")
    sys.exit(2)
elif has_try and (has_catch or has_finally):
    print("OK: try block properly closed with catch/finally")
else:
    print("OK: no try block found")
PY

Repository: OlufunbiIK/StellarSplit

Length of output: 1212


Fix parse-breaking try block in handleCopyUri.

At lines 50-59, try block is missing required catch or finally clause, causing syntax error.

Proposed fix
   const handleCopyUri = async () => {
     if (!paymentUri) {
       return;
     }
-
-    try {
-      // Issue `#488` — use browserShare adapter
-      const result = await copyToClipboard(paymentUri);
-      if (result.success) {
-        setCopied(true);
-        window.setTimeout(() => setCopied(false), 1500);
-      } else {
-        setError(result.error.message);
-      }
+    // Issue `#488` — use browserShare adapter
+    const result = await copyToClipboard(paymentUri);
+    if (result.success) {
+      setCopied(true);
+      window.setTimeout(() => setCopied(false), 1500);
+    } else {
+      setError(result.error.message);
+    }
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await navigator.clipboard.writeText(paymentUri);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (copyError) {
setError((copyError as Error).message);
}
// Issue #488 — use browserShare adapter
const result = await copyToClipboard(paymentUri);
if (result.success) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setError(result.error.message);
}
};
const handleCopyUri = async () => {
if (!paymentUri) {
return;
}
// Issue `#488` — use browserShare adapter
const result = await copyToClipboard(paymentUri);
if (result.success) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} else {
setError(result.error.message);
}
};
🧰 Tools
🪛 Biome (2.4.13)

[error] 59-59: Expected a catch clause but instead found ';'.

(parse)

🪛 GitHub Actions: CI

[error] 59-59: ESLint parsing error: 'catch' or 'finally' expected

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/Payment/QRCodeGenerator.tsx` around lines 50 - 59,
The try block in handleCopyUri is missing a catch/finally, causing a syntax
error; wrap the await copyToClipboard(paymentUri) call inside try { ... } and
add a catch (err) { setError(err?.message || String(err)); } (or a finally) so
errors are handled and the block is syntactically correct; reference the
handleCopyUri function, the copyToClipboard call, and the state setters
setCopied and setError when adding the catch/finally.

Comment on lines +61 to +102
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
// Always release hardware on unmount
if (streamRef.current) {
stopCameraStream(streamRef.current);
streamRef.current = null;
}
};
}, []);

const stopStream = useCallback(() => {
if (streamRef.current) {
stopCameraStream(streamRef.current);
streamRef.current = null;
}
if (mountedRef.current) setStatus('stopped');
}, []);

const startStream = useCallback(async () => {
if (!mountedRef.current) return;
setStatus('requesting');
setError(null);

try {
const stream = await requestCameraPermission(constraints);
streamRef.current = stream;
if (mountedRef.current) setStatus('active');
} catch (err) {
const message = getUserFriendlyErrorMessage(err as Error);
if (mountedRef.current) {
setStatus('error');
setError({ type: 'unknown', message });
}
}
}, [constraints]);

const restartStream = useCallback(async () => {
stopStream();
await startStream();
}, [stopStream, startStream]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalidate pending camera requests on stop/unmount.

requestCameraPermission() can resolve after stopStream() or the unmount cleanup already ran. In that case the hook still writes the stream into streamRef.current and can flip back to active, leaking camera access after teardown.

🔒 Recommended fix
 export function useCameraStream({
   constraints = { video: { facingMode: 'environment' }, audio: false },
   autoStart = false,
 }: UseCameraStreamOptions = {}): UseCameraStreamReturn {
+  const requestIdRef = useRef(0);
   const [status, setStatus] = useState<CameraStreamStatus>('idle');
   const [error, setError] = useState<CameraStreamError | null>(null);
   const streamRef = useRef<MediaStream | null>(null);
@@
   useEffect(() => {
     mountedRef.current = true;
     return () => {
       mountedRef.current = false;
+      requestIdRef.current += 1;
       // Always release hardware on unmount
       if (streamRef.current) {
         stopCameraStream(streamRef.current);
         streamRef.current = null;
       }
@@
   const stopStream = useCallback(() => {
+    requestIdRef.current += 1;
     if (streamRef.current) {
       stopCameraStream(streamRef.current);
       streamRef.current = null;
     }
     if (mountedRef.current) setStatus('stopped');
   }, []);
@@
   const startStream = useCallback(async () => {
     if (!mountedRef.current) return;
+    const requestId = ++requestIdRef.current;
     setStatus('requesting');
     setError(null);
 
     try {
       const stream = await requestCameraPermission(constraints);
+      if (!mountedRef.current || requestId !== requestIdRef.current) {
+        stopCameraStream(stream);
+        return;
+      }
       streamRef.current = stream;
       if (mountedRef.current) setStatus('active');
     } catch (err) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useCameraStream.ts` around lines 61 - 102, Pending camera
requests can resolve after stopStream/unmount and reassign streamRef or
setStatus to 'active'; to fix, add a cancellable request token (e.g.,
requestIdRef or a local closure id inside startStream) and check it before
mutating state or streamRef in startStream's success/error paths, and
increment/clear that token inside stopStream and the unmount cleanup to
invalidate outstanding requests; use the existing identifiers
requestCameraPermission, startStream, stopStream, streamRef, and mountedRef when
implementing the token check so late-resolving promises do nothing.

Comment on lines +90 to +95
} catch (err) {
const message = getUserFriendlyErrorMessage(err as Error);
if (mountedRef.current) {
setStatus('error');
setError({ type: 'unknown', message });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve the structured camera error type.

The catch block always stores type: 'unknown', which throws away the permission-denied / not-found / not-secure detail already exposed by cameraPermissions.ts. Consumers of this hook can't branch on the actual failure mode anymore.

💡 Suggested fix
     } catch (err) {
-      const message = getUserFriendlyErrorMessage(err as Error);
+      const cameraError = err as Error & { permissionError?: CameraStreamError };
+      const message = getUserFriendlyErrorMessage(cameraError);
       if (mountedRef.current) {
         setStatus('error');
-        setError({ type: 'unknown', message });
+        setError({
+          type: cameraError.permissionError?.type ?? 'unknown',
+          message,
+        });
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/useCameraStream.ts` around lines 90 - 95, The catch in
useCameraStream currently hardcodes setError({ type: 'unknown', message }),
losing structured camera error details; update the handler to preserve the
original camera error type when available (e.g., read (err as CameraError).type
or a cameraErrorType helper) and pass that into setError instead of 'unknown',
while still using getUserFriendlyErrorMessage(err) for message and guarding with
mountedRef.current before calling setStatus and setError.

Comment on lines +29 to +32
const handleAppInstalled = () => {
promptRef.current = null;
setCanInstall(false);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache the prompt event before awaiting.

installApp() reads promptRef.current again after prompt() resolves, but appinstalled clears that ref independently. If the event fires in between, this becomes a null dereference and breaks the install flow.

🔧 Proposed fix
 const installApp = useCallback(async (): Promise<InstallOutcome | null> => {
-  if (!promptRef.current) return null;
-  await promptRef.current.prompt();
-  const { outcome } = await promptRef.current.userChoice;
+  const prompt = promptRef.current;
+  if (!prompt) return null;
+  await prompt.prompt();
+  const { outcome } = await prompt.userChoice;
   promptRef.current = null;
   setCanInstall(false);
   return outcome;
 }, []);

Also applies to: 47-54

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/usePWA.ts` around lines 29 - 32, The install flow can race
with the appinstalled handler which clears promptRef.current; update installApp
to copy promptRef.current into a local variable (e.g., savedPrompt =
promptRef.current) before calling await savedPrompt.prompt() and use that
savedPrompt for subsequent checks and showPrompt calls to avoid null
dereference; do the same for the second occurrence where promptRef.current is
used after an await, and leave handleAppInstalled as-is (it should still clear
promptRef.current).

Comment on lines +75 to +80
async markSynced(id: string): Promise<void> {
const db = await getDb();
const split = await db.get(STORES.splits, id) as OfflineSplit | undefined;
if (split) {
await db.put(STORES.splits, { ...split, pendingSync: false });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the record updates atomic.

markSynced and recordAttempt both read a record and write back a mutated copy in separate awaits, so concurrent callers can clobber newer fields or lose an increment. recordAttempt also clears lastError whenever error is omitted, which drops the previous failure context.

🔧 Proposed fix
 async markSynced(id: string): Promise<void> {
   const db = await getDb();
-  const split = await db.get(STORES.splits, id) as OfflineSplit | undefined;
-  if (split) {
-    await db.put(STORES.splits, { ...split, pendingSync: false });
-  }
+  const tx = db.transaction(STORES.splits, 'readwrite');
+  const store = tx.objectStore(STORES.splits);
+  const split = await store.get(id) as OfflineSplit | undefined;
+  if (split) {
+    await store.put({ ...split, pendingSync: false });
+  }
+  await tx.done;
 }
@@
 async recordAttempt(id: string, error?: string): Promise<void> {
   const db = await getDb();
-  const payment = await db.get(STORES.queuedPayments, id) as QueuedPayment | undefined;
-  if (payment) {
-    await db.put(STORES.queuedPayments, {
-      ...payment,
-      attempts: payment.attempts + 1,
-      lastError: error,
-    });
-  }
+  const tx = db.transaction(STORES.queuedPayments, 'readwrite');
+  const store = tx.objectStore(STORES.queuedPayments);
+  const payment = await store.get(id) as QueuedPayment | undefined;
+  if (payment) {
+    await store.put({
+      ...payment,
+      attempts: payment.attempts + 1,
+      ...(error !== undefined ? { lastError: error } : {}),
+    });
+  }
+  await tx.done;
 }

Also applies to: 112-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/utils/offlineRepository.ts` around lines 75 - 80, markSynced and
recordAttempt perform separate read and write awaits which can race and clobber
concurrent updates; change both to perform get+modify+put inside a single
readwrite transaction (use getDb(), start a transaction on STORES.splits, call
tx.store.get(id), update the record and tx.store.put(updated) before committing)
so the read/modify/write is atomic, and update recordAttempt to only clear
lastError when an explicit null/empty indicator is provided (otherwise preserve
existing lastError) while incrementing the attempt counter using the value read
inside the same transaction; reference functions: markSynced, recordAttempt,
getDb, STORES.splits, and OfflineSplit.

@OlufunbiIK OlufunbiIK merged commit 8677346 into OlufunbiIK:main Apr 28, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants