From b3d8bf08f11a80466f79f8619890038ddd0b6f9f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 23 Aug 2026 10:08:23 +0200 Subject: [PATCH] fix(mobile): local dates, revoked object URLs, and writes that said nothing Four screens derived "today" from toISOString(), which is the UTC calendar day: an expense filed at 17:00 in Los Angeles was dated tomorrow, a to-do went overdue eight hours early in Tokyo, and a new journal entry got the wrong day either side of midnight. All four take the viewer's own calendar day now, through the helper the planner already had. Three sheets built preview object URLs in render and never revoked them, so scrolling a picker leaked one blob per pass. Photo reorder fired its PATCHes from inside a setState updater, which StrictMode double-invokes. And several writes failed in silence: the notification toggle, the collab feature switch rolling back to a stale snapshot, the appearance change whose debounced write was dropped on unmount so it reverted on the next reload. The backup-code print page skipped the escaping its desktop twin does, the clipboard copy threw on a non-HTTPS install, and reservation metadata was JSON.parsed unguarded in render, which takes the whole route down. --- client/src/mobile/screens/admin/MAdmin.tsx | 10 ++++--- .../screens/dashboard/MNewTripSheet.tsx | 7 +++++ .../mobile/screens/journey/MJourneyDetail.tsx | 9 ++++--- .../screens/journey/MJourneyEntrySheet.tsx | 23 +++++++++------- .../screens/journey/MJourneySettingsSheet.tsx | 27 +++++++++++++++---- .../screens/settings/MSettingsAccount.tsx | 3 ++- .../screens/settings/MSettingsAppearance.tsx | 12 +++++++-- .../settings/MSettingsNotifications.tsx | 8 +++++- .../screens/trip/plan/MPlanTimeline.tsx | 4 +-- .../mobile/screens/trip/sheets/MCostSheet.tsx | 6 +++-- .../screens/trip/sheets/MReservationSheet.tsx | 3 ++- .../screens/trip/tabs/MFileLightbox.tsx | 8 +++++- .../mobile/screens/trip/tabs/MTodoListTab.tsx | 5 +++- .../mobile/screens/trip/tabs/costsModel.ts | 6 ++++- 14 files changed, 98 insertions(+), 33 deletions(-) diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index 5f4923281..5130ba5f2 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -230,12 +230,14 @@ export default function MAdmin() { }} collabFeatures={collabFeatures} onToggleCollabFeature={async (key: string) => { - const next = { ...collabFeatures, [key]: !collabFeatures[key] } - setCollabFeatures(next) + const previous = collabFeatures[key] + setCollabFeatures({ ...collabFeatures, [key]: !previous }) try { - await adminApi.updateCollabFeatures({ [key]: next[key] }) + await adminApi.updateCollabFeatures({ [key]: !previous }) } catch { - setCollabFeatures(collabFeatures) + // Only this key rolls back — a slower request must not undo a toggle + // the admin made in the meantime. + setCollabFeatures(prev => ({ ...prev, [key]: previous })) } }} /> diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index 654e054e0..e046b0a54 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -89,6 +89,13 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda setError('') }, [trip, open]) + // The local file preview is a blob url; release it once a new cover replaces it + // or the sheet goes away. Server and Unsplash urls are left alone. + useEffect(() => { + if (!coverPreview?.startsWith('blob:')) return + return () => { URL.revokeObjectURL(coverPreview) } + }, [coverPreview]) + // Moving the start keeps the trip length (same rule as TripFormModal). const changeStart = (value: string) => { if (value && endDate && startDate && endDate >= startDate) { diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx index 012c17dd3..a98175d3c 100644 --- a/client/src/mobile/screens/journey/MJourneyDetail.tsx +++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx @@ -135,7 +135,9 @@ export default function MJourneyDetail() { const [uploading, setUploading] = useState(false) useEffect(() => { - (async () => { + let active = true + const ac = new AbortController() + ;(async () => { try { const addonsData = await addonsApi.enabled() const enabled = (addonsData.addons || []).filter( @@ -144,13 +146,14 @@ export default function MJourneyDetail() { const connected: { id: string; name: string }[] = [] for (const p of enabled) { try { - const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' }) + const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include', signal: ac.signal }) if (res.ok && (await res.json()).connected) connected.push({ id: p.id, name: p.name }) } catch { /* provider stays hidden */ } } - setAvailableProviders(connected) + if (active) setAvailableProviders(connected) } catch { /* no providers */ } })() + return () => { active = false; ac.abort() } }, []) const handleGalleryUpload = async (e: React.ChangeEvent) => { diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index 33f4db055..9692cb6ab 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -101,6 +101,11 @@ export default function MJourneyEntrySheet({ [addons], ) + // Minting the preview inside the tile markup would hand out a fresh blob URL on + // every keystroke in the story field, and nothing would ever release them. + const pendingPreviews = useMemo(() => pendingFiles.map(f => URL.createObjectURL(f)), [pendingFiles]) + useEffect(() => () => { pendingPreviews.forEach(url => URL.revokeObjectURL(url)) }, [pendingPreviews]) + useEffect(() => { if (readOnly) return // App.tsx loads the addon list on boot, so this only reacts to it. Kicking off @@ -536,13 +541,13 @@ export default function MJourneyEntrySheet({