Build1 publisher3 min readPublished Updated
A slow PDF build outlives the tap that authorised navigator.share()
LensUp's scanner runs entirely in the browser tab. On a mid-range phone the PDF is ready only after the tap's transient activation has expired. The team now reads navigator.userActivation.isActive and degrades to a second tap.
The Engineer · Build desk
What happened
- LensUp does the whole photograph-to-PDF pipeline inside the browser tab, with no upload, and the post covers the platform problems that cost more debugging time than the image processing.
- The fix splits preparing from sharing: the build caches a file plus a settings identity, and a second tap shares it with no await in between when the identity still matches.
- Corner detection moved off the main thread into a worker spawned for a single image and then terminated, instead of the long-lived worker or pool with request IDs the post calls the obvious design.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Activation cannot be renewed, so any in-tab pipeline whose output takes longer than one activation window has to be designed for two taps, even on devices where the second tap will never fire.
- exposure The failure only appears on slow hardware, so a bench of fast phones will certify a share flow that breaks for the users with the least capable devices.
- decision Every catch block around share() now needs a name check, because the alternative is telling a user who cancelled the sheet that their file failed.
- cost Choosing a worker per image over a pool makes cancellation a terminate() call and charges a fresh worker startup to every page in a multi-page scan.
The naive handler awaits buildPdf(pages) and then calls navigator.share(); the await is where the permission goes [3]. Transient activation belongs to the tap that started the handler. The LensUp post on dev.to says there is no way to extend it [5]. On a mid-range phone, a multi-page PDF built from full-resolution photos takes long enough that share() rejects with NotAllowedError and the sheet never opens [4].
In the reworked handler, the second tap does no await before share(). The file is already sitting in preparedShare, and it is reused only if sameOutputIdentity() agrees that the cached settings identity still matches a fresh snapshot of the current output [6]. A comment in the code says a long render may outlive transient user activation [8].
Before it commits, the handler reads navigator.userActivation?.isActive. False means the render outlived the tap, so it shows a toast saying the file is ready and to tap share again, then returns [7]. The post says the flag lets you "degrade to an honest two-tap flow instead of showing an error for something the user did nothing wrong in" [9]. On a fast device one tap does the job [10].
Detection of the capability is a separate problem. 'share' in navigator can be true while file sharing is unsupported, because file support varies independently [11]. The probe in the post builds a File named t.pdf with type application/pdf from new Uint8Array([37, 80, 68, 70]) and hands it to navigator.canShare, returning false if the constructor throws [12]. Those four bytes [21] spell %PDF [13]; canShare never has to open it. The post reports that canShare can accept one type and refuse another [13]. When the probe fails, the share button is hidden [14].
"AbortError is not an error", the post says [16]. Dismissing the share sheet rejects the promise with AbortError, so a catch that toasts every rejection reports a failure to a user who simply changed their mind; the guard is one condition on err.name [15].
Three of the four defects described in the excerpt sit in the Web Share API's contract. The fourth is scheduling [22]. Corner finding is the one genuinely CPU-heavy step, and the post says "it has no business on the main thread while someone is trying to scroll" [17]. The obvious design is a long-lived worker or a small pool plus request IDs so responses match requests; the post says they ended up with the opposite, spawning a worker for one image and terminating it [18]. detectQuadFromBlob returns null before doing any work when the blob is missing or options.signal is already aborted [19]. The excerpt ends mid-function [23].
Whether the share bug appears in your build depends on hardware you may not be testing. The post reports it reproduces only on slow devices and calls that "the worst possible failure profile" [4][20]. For one tap to be enough, the whole build has to finish inside a single activation window on the slowest phone you support, at the page count and resolution your users actually feed it [4]. The author discloses that LensUp is their own tool and that the post covers the parts that were harder than the pipeline [2].
What to watch
- Whether any browser ships a way to re-acquire or extend transient activation for long async work, which would remove the second tap.
- A measured per-image worker spawn cost on a mid-range phone, which would show whether spawn-and-terminate beats a pool on a multi-page batch.
- Whether canShare's accepted MIME types diverge enough across browsers that one application/pdf probe stops being sufficient.