fix(lightbox): eliminate download lag on Android by skipping the blob round-trip

`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
This commit is contained in:
Paul Nothaft
2026-05-27 13:16:36 +02:00
parent 9ae1f79769
commit 04795219a0
2 changed files with 76 additions and 42 deletions
@@ -51,6 +51,7 @@ describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
vi.spyOn(galleryService, 'fetchPhotoBlob').mockResolvedValue(fetchedBlob as any); vi.spyOn(galleryService, 'fetchPhotoBlob').mockResolvedValue(fetchedBlob as any);
vi.spyOn(galleryService, 'triggerBrowserDownload').mockImplementation(() => undefined); vi.spyOn(galleryService, 'triggerBrowserDownload').mockImplementation(() => undefined);
vi.spyOn(galleryService, 'triggerDirectDownload').mockImplementation(() => undefined);
}); });
afterEach(() => { afterEach(() => {
@@ -69,13 +70,18 @@ describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
expect(share).toHaveBeenCalledTimes(1); expect(share).toHaveBeenCalledTimes(1);
expect(canShare).toHaveBeenCalledWith({ files: expect.any(Array) }); expect(canShare).toHaveBeenCalledWith({ files: expect.any(Array) });
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled(); expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
}); });
it('falls through to a regular download on Android even when canShare({files}) is true', async () => { it('navigates straight to the download URL on Android (no blob round-trip)', async () => {
// This is the bug #554 fixes — canShare is true on Chrome Android too, // #554 fix: canShare is true on Chrome Android but the share sheet
// but the Android share sheet has no "Save Image" action so the user // has no "Save Image" action, so the share path is iOS-only. The
// sees a useless app-picker. The gating must be UA-based, not // follow-up issue (Rekoo-PS, post-#556) was that the Android
// capability-based. // fallback fetched the blob through JS before clicking <a download>,
// adding ~5s of dead air before the browser's download UI appeared
// and prompting users to re-click. Going straight to the download
// URL hands the fetch to the browser, which shows its own progress
// immediately — no spinner needed.
const share = vi.fn().mockResolvedValue(undefined); const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true); const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: ANDROID_UA, share, canShare }); installNavigator({ userAgent: ANDROID_UA, share, canShare });
@@ -83,21 +89,23 @@ describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg'); await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).not.toHaveBeenCalled(); expect(share).not.toHaveBeenCalled();
// canShare may or may not be probed on Android; what matters is the expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
// share() call doesn't happen and a download is triggered instead. expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledTimes(1); expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledWith( expect(galleryService.triggerDirectDownload).toHaveBeenCalledWith(
fetchedBlob.blob, expect.stringMatching(/\/gallery\/slug\/download\/1$/),
'IMG_0001.jpg', 'fallback.jpg',
); );
}); });
it('falls through to a regular download on desktop Safari (no share / canShare APIs)', async () => { it('navigates straight to the download URL on desktop Safari (no share / canShare APIs)', async () => {
installNavigator({ userAgent: DESKTOP_UA }); installNavigator({ userAgent: DESKTOP_UA });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg'); await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledTimes(1); expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
}); });
it('detects iPadOS 13+ (reports as MacIntel + touch) as iOS', async () => { it('detects iPadOS 13+ (reports as MacIntel + touch) as iOS', async () => {
@@ -115,6 +123,7 @@ describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
expect(share).toHaveBeenCalledTimes(1); expect(share).toHaveBeenCalledTimes(1);
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled(); expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
}); });
it('does NOT treat a regular Mac (MacIntel + no touch) as iOS', async () => { it('does NOT treat a regular Mac (MacIntel + no touch) as iOS', async () => {
@@ -131,7 +140,7 @@ describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg'); await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).not.toHaveBeenCalled(); expect(share).not.toHaveBeenCalled();
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledTimes(1); expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
}); });
it('does not fall back to download when the user dismisses the iOS share sheet (AbortError)', async () => { it('does not fall back to download when the user dismisses the iOS share sheet (AbortError)', async () => {
+53 -28
View File
@@ -68,39 +68,49 @@ export const galleryService = {
}, },
// Save single photo. iOS routes through the Web Share API so the // Save single photo. iOS routes through the Web Share API so the
// share sheet's "Save Image" action lands the file in Photos; // share sheet's "Save Image" action lands the file in Photos.
// everywhere else (Android, desktop) uses a regular <a download> // Everywhere else (Android, desktop) navigates a hidden anchor
// because their share sheets don't expose a save-to-gallery // straight at the download URL — the browser's native download UI
// action — #531 originally extended this to "all mobile with // shows up immediately and its progress lives in the notification
// canShare", which surfaced a useless app-picker on Android (#554). // shade. Buffering the blob through fetch first (the original
// path) added ~5s of dead air on cellular before any visible
// feedback, prompting users to re-click and produce duplicate
// downloads (#554 follow-up). Direct navigation eliminates the
// latency outright rather than masking it with a spinner.
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> { async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
if (!isIOS()) {
this.triggerDirectDownload(
api.getUri({ url: `/gallery/${slug}/download/${photoId}` }),
filename,
);
return;
}
const fetched = await this.fetchPhotoBlob(slug, photoId); const fetched = await this.fetchPhotoBlob(slug, photoId);
const resolvedFilename = fetched.serverFilename || filename; const resolvedFilename = fetched.serverFilename || filename;
if (isIOS()) { // canShare() returns false on browsers without Web Share file
// canShare() returns false on browsers without Web Share file // support. Probe with a representative File so the negotiation
// support. Probe with a representative File so the negotiation // is accurate — `canShare({ files: [] })` returns true on some
// is accurate — `canShare({ files: [] })` returns true on some // browsers that don't actually accept files at share() time.
// browsers that don't actually accept files at share() time. const file = new File([fetched.blob], resolvedFilename, {
const file = new File([fetched.blob], resolvedFilename, { type: fetched.blob.type || 'image/jpeg',
type: fetched.blob.type || 'image/jpeg', });
}); const canShareFile =
const canShareFile = typeof navigator !== 'undefined' &&
typeof navigator !== 'undefined' && typeof navigator.canShare === 'function' &&
typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] });
navigator.canShare({ files: [file] });
if (canShareFile) { if (canShareFile) {
try { try {
await navigator.share({ files: [file], title: resolvedFilename }); await navigator.share({ files: [file], title: resolvedFilename });
return; return;
} catch (err) { } catch (err) {
// AbortError = user dismissed the share sheet. Don't fall back — // AbortError = user dismissed the share sheet. Don't fall back —
// they made a choice. Any other failure (NotAllowedError, // they made a choice. Any other failure (NotAllowedError,
// DataError, etc.) is unexpected; surface a download instead so // DataError, etc.) is unexpected; surface a download instead so
// the user still gets the file. // the user still gets the file.
if ((err as DOMException)?.name === 'AbortError') return; if ((err as DOMException)?.name === 'AbortError') return;
}
} }
} }
@@ -157,6 +167,21 @@ export const galleryService = {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
// Trigger a browser-native download by navigating a hidden anchor at
// the URL directly. The browser fetches the response itself (showing
// its own progress UI), so unlike triggerBrowserDownload the JS layer
// never materialises the bytes. `filename` is a hint; the server's
// Content-Disposition wins per spec, which is what carries the #493
// original-camera-filename setting through to disk.
triggerDirectDownload(href: string, filename: string): void {
const link = document.createElement('a');
link.href = href;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
},
// Download single photo — kept as the canonical name for the existing // Download single photo — kept as the canonical name for the existing
// grid + lightbox-action callers that haven't been migrated to the // grid + lightbox-action callers that haven't been migrated to the
// share-aware savePhotoToDevice path yet. // share-aware savePhotoToDevice path yet.