TLDR: iOS Web Share kills the share if anything async happens between the tap and the
navigator.share()call. Every asset, every byte, has to be synchronously ready before the user's finger comes down.
The Feature Sounded Straightforward
My workout-tracking PWA for dads who actually want to lift got a new button on every logged workout: πͺ Share workout.
Tap it, and you get a 1080Γ1080@2x PNG card β dark background, orange accents, full exercise list with per-set detail, a little dad sprite in the corner at your current level.
One button. How complicated could it be?
What Broke Immediately
canvas.toBlob() is async.
That's the natural way to get a Blob out of a <canvas> element, and it's what every example shows. But iOS Web Share has exactly one rule: you have to call navigator.share() inside the original user gesture. The moment you await anything β a callback, a Promise, a toBlob() resolution β iOS says the gesture is spent and silently refuses the share.
No error. No rejection message. The sheet just⦠never opens.
I spent too long debugging this thinking the data was wrong. It wasn't. The timing was wrong.
The Fix: Make the Blob Sync
canvas.toDataURL() is synchronous. So I swapped to that, then wrote a small dataURLtoBlob helper that converts the base64 string to a Uint8Array and wraps it in new Blob() β all synchronous, all in-gesture.
// stays inside the user gesture
const dataURL = canvas.toDataURL('image/png');
const blob = dataURLtoBlob(dataURL); // sync: atob β Uint8Array β new Blob()
await navigator.share({ files: [new File([blob], 'workout.png', { type: 'image/png' })] });
That fixed half the problem.
The Other Half: The Sprite
The workout card has a dad sprite β a pixel-art character at the user's current level. Drawing it onto the canvas requires a loaded Image object.
Loading is also async.
If I tried to draw it at share time, I'd have to wait for img.onload, which blows the gesture for the same reason. So I moved the load earlier: preloadShareSprite() runs on renderProgress β when the workout screen first appears β and caches the Image element.
By the time the user taps πͺ, the sprite is already sitting in memory. The drawImage() call in workoutCardCanvas is synchronous. Nothing awaits. The gesture survives.
That's the real lesson.
Why It Matters
This is what "state management" looks like on a constrained platform. It's not a reducer or a global store. It's the discipline of asking: what does this action need to be true before the user starts it?
iOS Web Share is just an honest teacher. It forces you to make your dependencies explicit β preload the image, build the blob synchronously, have the data ready β or it doesn't run.
I shipped it in commit c95e184 (SW v17βv18). Verified it works on a real phone because, as I noted in the Apollo memory, desktop CDP can't fire iOS Web Share. There's no shortcut for that final test.
Hold the phone, tap the button, watch the share sheet open. That's the confirm.
P.S. One wrinkle I left for later: if the sprite fails to preload (flaky connection, cold load), the card still generates β it just ships without the sprite. Silent degradation is fine. A broken share sheet because I tried to be clever about async is not.