Prevent blob eviction: store as ArrayBuffer + request persistence

iOS Safari evicts Blob file-backed data from IndexedDB under memory
pressure, causing upload POSTs to throw 'Load failed' without ever
reaching the server. Two-pronged fix:

1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file
   reference) in IndexedDB — ArrayBuffers are not subject to eviction
2. Request navigator.storage.persist() to signal the browser not to
   evict our storage under pressure

Also adds Storage.getBlob() helper for converting stored ArrayBuffer
back to Blob at upload/display time, with backward compat for any
existing Blob-stored photos.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 16:01:07 -06:00
parent 1da3eea7b8
commit 5105b42c46
5 changed files with 48 additions and 57 deletions

View File

@@ -14,29 +14,34 @@ const Storage = {
settings: '++id, username, [username+key]' settings: '++id, username, [username+key]'
}); });
// Request persistent storage so the browser won't evict our data
this.requestPersistence();
return this.db.open(); return this.db.open();
}, },
async requestPersistence() {
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log('[STORAGE] Persistence:', granted ? 'granted' : 'denied');
}
},
async savePhoto(photoData) { async savePhoto(photoData) {
/** // Convert Blob to ArrayBuffer for storage resilience.
* Save a photo to IndexedDB // iOS Safari can evict Blob file-backed data from IndexedDB,
* photoData: { // but ArrayBuffer is stored inline and won't be evicted.
* username: string, let imageData = photoData.blob;
* timestamp: number, if (imageData instanceof Blob) {
* filename: string, imageData = await imageData.arrayBuffer();
* targetPath: string, }
* blob: Blob,
* status: 'pending' | 'uploading' | 'uploaded' | 'verified'
* retryCount: number,
* lastError: string
* }
*/
const id = await this.db.photos.add({ const id = await this.db.photos.add({
username: photoData.username, username: photoData.username,
timestamp: photoData.timestamp, timestamp: photoData.timestamp,
filename: photoData.filename, filename: photoData.filename,
targetPath: photoData.targetPath, targetPath: photoData.targetPath,
blob: photoData.blob, blob: imageData,
status: photoData.status || 'pending', status: photoData.status || 'pending',
retryCount: photoData.retryCount || 0, retryCount: photoData.retryCount || 0,
lastError: photoData.lastError || null lastError: photoData.lastError || null
@@ -45,6 +50,14 @@ const Storage = {
return id; return id;
}, },
// Convert stored photo data back to a Blob for upload or display.
// Handles both legacy Blob storage and new ArrayBuffer storage.
getBlob(photo) {
if (!photo || !photo.blob) return null;
if (photo.blob instanceof Blob) return photo.blob;
return new Blob([photo.blob], { type: 'image/jpeg' });
},
async getPhoto(id) { async getPhoto(id) {
return await this.db.photos.get(id); return await this.db.photos.get(id);
}, },

View File

@@ -137,31 +137,18 @@ const Sync = {
return true; return true;
} }
const blob = freshPhoto.blob; // Convert stored data (ArrayBuffer or legacy Blob) to a Blob for upload
const blob = Storage.getBlob(freshPhoto);
console.log('[SYNC] Uploading:', photo.filename, console.log('[SYNC] Uploading:', photo.filename,
'(' + (blob ? (blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')', '(' + (blob ? (blob.size / 1024 / 1024).toFixed(1) + 'MB' : 'no blob') + ')',
'attempt', retryCount + 1, 'of', this.MAX_RETRIES); 'attempt', retryCount + 1, 'of', this.MAX_RETRIES);
// Validate blob reference exists // Validate blob exists and has content
if (!blob || !(blob instanceof Blob) || blob.size === 0) { if (!blob || blob.size === 0) {
throw new Error('Photo data is missing or corrupted - please delete and re-capture'); throw new Error('Photo data is missing or corrupted - please delete and re-capture');
} }
// Actually try to read the blob to verify data is accessible
// (iOS Safari can evict blob data from IndexedDB while keeping the reference)
const readable = await this.validateBlobReadable(blob);
if (!readable) {
// Blob data is gone — no amount of retrying will fix this
console.error('[SYNC] Blob data is no longer readable:', photo.filename);
await Storage.updatePhoto(photo.id, {
status: 'failed',
lastError: 'Photo data was lost by the browser - please re-capture',
error: 'Photo data was lost by the browser - please re-capture'
});
return false;
}
await Storage.updatePhoto(photo.id, { status: 'uploading' }); await Storage.updatePhoto(photo.id, { status: 'uploading' });
// Prevent page navigation during upload // Prevent page navigation during upload
@@ -294,19 +281,6 @@ const Sync = {
} }
}, },
async validateBlobReadable(blob) {
try {
// Try to read the first byte — if iOS has evicted the data,
// this will throw even though the Blob reference looks valid
const slice = blob.slice(0, 1);
const buf = await slice.arrayBuffer();
return buf.byteLength > 0;
} catch (e) {
console.error('[SYNC] Blob readability check failed:', e.message);
return false;
}
},
async checkFileExists(path) { async checkFileExists(path) {
try { try {
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path); const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);

View File

@@ -1,9 +1,9 @@
// NextSnap Service Worker // NextSnap Service Worker
// Provides offline-first caching for the app shell // Provides offline-first caching for the app shell
const CACHE_VERSION = 'nextsnap-v14'; const CACHE_VERSION = 'nextsnap-v15';
const APP_SHELL_CACHE = 'nextsnap-shell-v10'; const APP_SHELL_CACHE = 'nextsnap-shell-v11';
const RUNTIME_CACHE = 'nextsnap-runtime-v10'; const RUNTIME_CACHE = 'nextsnap-runtime-v11';
// Assets to cache on install // Assets to cache on install
const APP_SHELL_ASSETS = [ const APP_SHELL_ASSETS = [

View File

@@ -631,7 +631,9 @@ async function loadRecentPhotos() {
container.innerHTML = ''; container.innerHTML = '';
for (const photo of photos) { for (const photo of photos) {
const url = URL.createObjectURL(photo.blob); const blob = Storage.getBlob(photo);
if (!blob) continue;
const url = URL.createObjectURL(blob);
const statusClass = photo.status === 'verified' ? 'verified' : const statusClass = photo.status === 'verified' ? 'verified' :
photo.status === 'uploading' ? 'uploading' : 'pending'; photo.status === 'uploading' ? 'uploading' : 'pending';

View File

@@ -452,9 +452,10 @@ function createQueueItem(photo, isCompleted) {
// Create thumbnail (use placeholder if blob was stripped) // Create thumbnail (use placeholder if blob was stripped)
const thumbnail = document.createElement('div'); const thumbnail = document.createElement('div');
thumbnail.className = 'queue-item-thumbnail'; thumbnail.className = 'queue-item-thumbnail';
if (photo.blob && photo.blob.size > 0) { const photoBlob = Storage.getBlob(photo);
if (photoBlob && photoBlob.size > 0) {
const img = document.createElement('img'); const img = document.createElement('img');
img.src = URL.createObjectURL(photo.blob); img.src = URL.createObjectURL(photoBlob);
thumbnail.appendChild(img); thumbnail.appendChild(img);
} else { } else {
thumbnail.innerHTML = '<span class="thumbnail-placeholder">' + thumbnail.innerHTML = '<span class="thumbnail-placeholder">' +
@@ -472,8 +473,8 @@ function createQueueItem(photo, isCompleted) {
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'queue-item-meta'; meta.className = 'queue-item-meta';
const date = new Date(photo.completedAt || photo.timestamp).toLocaleString(); const date = new Date(photo.completedAt || photo.timestamp).toLocaleString();
if (photo.blob && photo.blob.size > 0) { if (photoBlob && photoBlob.size > 0) {
const size = (photo.blob.size / 1024 / 1024).toFixed(2); const size = (photoBlob.size / 1024 / 1024).toFixed(2);
meta.textContent = size + ' MB \u2022 ' + date; meta.textContent = size + ' MB \u2022 ' + date;
} else { } else {
meta.textContent = date; meta.textContent = date;
@@ -552,8 +553,9 @@ async function updateStats() {
const pendingCount = photos.filter(p => p.status === 'pending').length; const pendingCount = photos.filter(p => p.status === 'pending').length;
const uploadingCount = photos.filter(p => p.status === 'uploading').length; const uploadingCount = photos.filter(p => p.status === 'uploading').length;
const totalSize = photos const totalSize = photos
.filter(p => p.blob && p.blob.size) .map(p => Storage.getBlob(p))
.reduce((sum, p) => sum + p.blob.size, 0) / 1024 / 1024; .filter(b => b && b.size)
.reduce((sum, b) => sum + b.size, 0) / 1024 / 1024;
document.getElementById('pending-stat').textContent = pendingCount; document.getElementById('pending-stat').textContent = pendingCount;
document.getElementById('uploading-stat').textContent = uploadingCount; document.getElementById('uploading-stat').textContent = uploadingCount;