DEV Community

Cover image for I shipped a download that iOS turned into a navigation
Isaiah Kim
Isaiah Kim

Posted on • Originally published at kynth.studio

I shipped a download that iOS turned into a navigation

I read a session from an iPhone where the visitor pressed an export button four times, and $pageleave fired about 150 milliseconds after each press, with a reset page when they came back. Those four departures were the export itself.

WebKit does not honour the download attribute on a blob: URL. On iOS and iPadOS, where every browser is WebKit underneath regardless of what it says on the icon, a.download pointed at a blob is ignored and the click becomes a navigation to that blob. The page unloads. Whatever state produced the file goes with it.

That pattern is everywhere in my code, so I went and fixed the place where it costs the most.

What the page was holding

PartsProof is a Cyber Resilience Act tool I'm building under Kynth, and its free surface is an SBOM generator. You give it a public GitHub repo and an email, and it returns a CycloneDX 1.5 document you can download.

The work behind that is not cheap. src/app/api/sbom/route.ts resolves the repo and its default branch, pulls the full recursive tree, filters that down to dependency manifests, then fetches and parses up to thirty of them. MAX_MANIFESTS is 30, so the whole run is as many as thirty-two GitHub round-trips before a single component is counted.

Because it takes real time, the route streams NDJSON and fires a stage as each piece finishes, with the measured elapsed per stage. The five ids live in one file, src/lib/stages.ts, with a note on top I put there for myself:

/* ⛔ Adding a label here does not create a stage. A stage exists when the route fires it. */
export type StageId = "repo" | "tree" | "manifests" | "bom" | "lead";
Enter fullscreen mode Exit fullscreen mode

So on a phone, the sequence was: wait through thirty-two round-trips, watch five stages report, get told how many distinct components came back, press download, and land on a blob viewer with the whole result gone. Pressing back gives you an empty form. The only way to see the number again is to run all of it again.

I shipped a download that iOS turned into a navigation — code

The branch, and the iPad that says it is a Mac

The fix in src/components/Lookup.tsx is small. On WebKit mobile, stop pretending the anchor is a download and let it be what WebKit is going to make it anyway: a navigation, but into its own tab, where the document opens with a share sheet instead of destroying the page that produced it.

const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent || '';
const webkitMobile =
  /iP(hone|od|ad)/.test(ua) ||
  (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (webkitMobile) {
  aEl.target = '_blank'; aEl.rel = 'noopener';
}
document.body.appendChild(aEl); aEl.click(); aEl.remove();
Enter fullscreen mode Exit fullscreen mode

The second clause of that test is the one I would have missed. An iPad running iPadOS reports navigator.platform as MacIntel and sends a desktop user agent string with no iPad in it, so the regex alone reads it as a laptop and leaves it broken. maxTouchPoints > 1 is what separates the iPad from an actual MacIntel machine, and a Mac with a trackpad reports 0. It is a sniff, and I do not love that it is a sniff, but there is no feature query for "this browser ignores an attribute".

I also considered the File System Access API and skipping the anchor entirely. Safari does not implement showSaveFilePicker, so the fallback path is this same anchor, and adding a branch that only ever runs on the browsers already working buys nothing.

I shipped a download that iOS turned into a navigation — architecture

Revoking on the next line

The other half of that commit is a bug that had been sitting there the whole time and would have been much harder to see.

document.body.appendChild(aEl); aEl.click(); aEl.remove();
URL.revokeObjectURL(url);   // ← this
Enter fullscreen mode Exit fullscreen mode

click() starts a save. It does not finish one. Revoking the object URL on the very next statement pulls the blob out from under a transfer that has only just been handed to the browser. On desktop with a small JSON body it wins the race essentially always, which is exactly why it survives review: it looks like tidy cleanup and it behaves like tidy cleanup right up until the document is large or the device is slow or the save goes through a share sheet.

It now reads:

// Revoking on the next line races the save the click just started; give it a window.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
Enter fullscreen mode Exit fullscreen mode

Sixty seconds of one blob held in memory is not a leak worth optimising against a file that sometimes does not arrive.

partsproof — live

Saying "downloaded" when nothing downloaded

There was one more thing wrong, and it took a second pass to notice. The success message was a single template string:

setSbomMsg(`downloaded ${repoName}-sbom.cdx.json.`);
Enter fullscreen mode Exit fullscreen mode

On the WebKit path that sentence is false. Nothing landed in a downloads folder. The document opened in its own tab and is waiting for the reader to press Share. Telling someone a file was saved when it was not is the specific failure this panel exists to avoid, since the whole product is about producing a document you can hand to somebody.

setSbomMsg(
  webkitMobile
    ? `${repoName}-sbom.cdx.json opened in a new tab — use Share to save it.`
    : `downloaded ${repoName}-sbom.cdx.json.`,
);
Enter fullscreen mode Exit fullscreen mode

The branch that changes behaviour and the branch that describes it have to be the same branch. Fixing the navigation without fixing the sentence would have left the tool confidently reporting a save on the one platform where it never performs one.

None of this showed up in a build, a type check, or any gate I run against the deployed page. Blob downloads are one of the last things in a web app that no automated check I have will exercise, because the interesting part happens after the click, in the browser's own plumbing, on a platform I was not testing on. The telemetry found it, and it found it as four unexplained departures rather than as an error.

https://partsproof.kynth.studio/?utm_source=founder-devto&utm_medium=social

Top comments (0)