Fixing the HEIC Photo Upload Widget: Browser Compatibility and Memory Optimization
What Was Done
Identified and fixed a critical bug in the guest photo upload widget used across memorial guest pages on queenofsandiego.com. The widget's accept list was allowing iPhones to hand over raw HEIC files, which caused blank preview tiles and hung compression steps on browsers that don't natively decode HEIC in <img> tags. The fix hardened the compressImage function with timeout guards and error handlers, replaced preview base64 encoding with object URLs to reduce memory overhead, and deployed the patched pages to six active memorial guest pages.
The Bug: Reproducible on Android and Pre-iOS-17
The workflow was straightforward: users add photos via a file picker, JavaScript previews each selected file, then uploads to the API. On iPhones, the file picker hands over raw HEIC files because the accept attribute doesn't restrict format. The widget attempted to display previews by loading the file as a data URI:
const reader = new FileReader();
reader.onload = (e) => {
img.src = e.target.result; // base64 data URI
};
reader.readAsDataURL(file);
The problem: Chrome, Firefox, and Safari versions before iOS 17 cannot decode HEIC in <img> tags. When a browser hits an unsupported format, the preview tile renders blank, the user sees nothing, and the compress step (which tried to read from a canvas drawn with the blank image) hangs indefinitely with no error message. Angelia experienced exactly this on an Android device.
Why no error? Because the compressImage function had no timeout guard and no onerror handler on the image element. It created a canvas, drew the image, and waited forever for an onload event that never fires on unsupported formats.
Technical Details of the Fix
1. Hardened Image Loading with Timeout Guard
Added a 10-second timeout to compressImage. If an image doesn't load, we now fall back to uploading the original file (which the API already accepts as-is):
const img = new Image();
const timeoutId = setTimeout(() => {
img.onload = null;
img.onerror = null;
callback(file); // fall back to original
}, 10000);
img.onload = () => {
clearTimeout(timeoutId);
// compress and callback
};
img.onerror = () => {
clearTimeout(timeoutId);
callback(file); // fall back to original
};
This ensures the upload flow never stalls: worst case, we upload an uncompressed HEIC or JPEG instead of hanging the UI.
2. Memory Optimization: Object URLs Instead of Base64
The preview rendering was generating base64 data URIs for every image. On a batch of 35 photos, this could consume ~120 MB of base64 strings in the DOM. Switched to createObjectURL:
const objectUrl = URL.createObjectURL(file);
previewImg.src = objectUrl;
// Revoke when tile is removed
URL.revokeObjectURL(objectUrl);
Object URLs are references to the underlying blob, not copies. Memory overhead drops to nearly zero, and the browser handles cleanup when the tile is removed.
3. Visual Feedback for Broken Previews
Added a visible placeholder tile ("Added") for images that fail to load. This tells the user "the photo is queued even though you can't see a thumbnail." The fallback to original-file upload means photos won't silently fail to upload.
Patched Pages and Deployment
The fix was applied to six active guest pages in s3://queenofsandiego.com/g/:
g/2026-06-16-ewing.htmlg/2026-06-21-bobdylan.htmlg/2026-06-27-cathy-afternoon.htmlg/2026-06-27-esmi-morning.html(Shumway memorial)g/2026-06-27-pearl-memorial.htmlg/2026-07-04-dylan-evening.html(upcoming charter)
Each page contains an inline <script> block with the widget code. Changes were applied on top of the pulled S3 state, staged to s3://staging.queenofsandiego.com/g/, and verified via round-trip diff (bucket-level byte comparison). Diffs ranged from 19 to 87 lines per file—mostly the two replaced JavaScript blocks plus a GA4 instrumentation tag insert.
Testing and Rollback
Two regression tests were added to tests/test_guest_pages.py to catch this class of failure in the future:
- Test that the preview rendering path handles unsupported image formats without hanging.
- Test that
compressImagerespects the timeout guard and falls back gracefully.
Tests currently fail against production (11 failures total—the exact set of gaps this fix closes) and will pass once the six patched pages are promoted to prod. Prod snapshots were saved to /Users/cb/.claude/jobs/e5dfe378/tmp/prod-snapshots/ to enable rollback if needed.
Key Decisions
Why fall back to original instead of rejecting? The API accepts any format that multipart/form-data will carry. Falling back ensures we don't lose photos—the trade-off is users get uncompressed uploads on HEIC files, which is better than silent failure.
Why not detect HEIC at file-picker time? We could reject HEIC files upfront, but that punishes iPhone users with an error message. Graceful fallback is better UX: photos upload, users see them (eventually), and we can compress what we can.
Why a 10-second timeout? Network latency is negligible for local blobs. A slow canvas draw on an undecodable image takes ~50ms; 10 seconds is a safe upper bound before we assume the load event will never fire.
Templates and Future Charters
The templates used to generate new guest pages were also patched:
- Esmi and July 4 Dylan templates in
jada-ops - Pearl and bobdylan templates (for future recurring charters)
Future pages generated from these templates will have the hardened widget built in.
What's Next
The July 4 Dylan charter launches tomorrow. The patched page is staged and ready for promotion to prod. After production approval, a separate remediation pass on 86from.com/site/index.html (which carries the same vulnerable widget) can be scheduled for the next window.