navigator.share appelé après la génération d’un PDF rejette avec NotAllowedError, parce que l’activation utilisateur a expiré pendant que le document se fabriquait. Dans une PWA de signalements (Astro 6.1, Svelte 5, Supabase, offline-first avec Dexie), l’export d’un dossier affichait « Erreur lors de la generation du PDF » à chaque clic, sans aucune erreur en console. Le PDF était pourtant généré : c’est sa remise qui échouait, et un catch unique attribuait l’échec à la mauvaise étape.
Le symptôme : un PDF généré mais jamais remis
Début août 2026, chaque export de dossier finissait sur le même message, sur tous les builds depuis le vendredi précédent, sans fichier produit, sans appel réseau, sans trace en console. L’écran enchaînait trois étapes dans un seul try : lire la base locale, générer le PDF avec jsPDF, puis le remettre par la feuille de partage native.
try {
const blob = await buildReport(dossier); // lecture Dexie + jsPDF
if (navigator.share && navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title: "Dossier de signalements" });
} else {
triggerDownload(blob, filename);
}
} catch {
error = "Erreur lors de la generation du PDF.";
}
Le catch sans paramètre avalait la cause et ne journalisait rien. Toute erreur de partage devenait une « erreur de génération ».
Pourquoi navigator.share rejette avec NotAllowedError ?
L’activation utilisateur transitoire est un état que le navigateur accorde à une page pendant un court délai après un geste (clic, tap, touche) ; certaines API, dont navigator.share, l’exigent. Le délai est de l’ordre de cinq secondes dans Chrome. Sur un vrai dossier, lire la base et fabriquer le PDF prend plus longtemps : au moment de l’appel, l’activation a expiré et share() rejette.
NotAllowedError: Must be handling a user gesture to perform a share request
Le geste utilisateur est une ressource qui s’épuise : tout ce qui s’intercale entre le clic et
share()le consomme.
Le correctif : le téléchargement comme filet
La remise est sortie dans un module pdf-delivery.ts. Le partage reste tenté, car il est meilleur sur mobile quand il aboutit, mais son échec ne fait plus perdre le document : il retombe sur le téléchargement. Seule une fermeture volontaire de la feuille (AbortError) laisse l’utilisateur sans fichier. L’écran, lui, journalise désormais la cause et l’affiche.
Le lendemain, une recette a trouvé un second piège : le bouton restait bloqué sur « Génération… ». navigator.share ne rejette pas quand sa feuille ne s’ouvre pas : la promesse reste en attente, pour toujours, et le repli n’est jamais atteint. L’attente est donc bornée à 20 s, un seuil volontairement large pour couvrir le temps de choisir une application dans la feuille.
export const SHARE_TIMEOUT_MS = 20_000;
function shareWithin(payload: ShareData, timeoutMs: number) {
return Promise.race([
navigator.share(payload).then(() => "shared" as const),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
]);
}
Le test de ce blocage échouait par timeout avant le correctif : une promesse qui ne se résout jamais est exactement ce qu’il fallait figer.
canShare({ files }) ne suffit pas : tester userActivation.isActive
Le soir même, la console d’un export montrait jusqu’à huit lignes NotAllowedError pour un seul clic. Le repli fonctionnait, mais ce bruit noyait les vraies erreurs. La détection de capacité était déjà en place : canShare({ files }) répond oui sur un navigateur de bureau, et c’est exact. Ce qui manque n’est pas la fonction, c’est le geste. navigator.userActivation.isActive répond précisément à cette question :
function activationStillUsable(): boolean {
const activation = (navigator as Navigator & { userActivation?: { isActive: boolean } })
.userActivation;
return activation === undefined || activation.isActive;
}
Un navigateur qui n’expose pas l’API tente le partage comme avant : le refuser sur une ignorance priverait du partage natif ceux qui en ont l’usage.
| Situation | Avant | Après |
|---|---|---|
| Partage réussi | partagé | partagé, une seule remise |
| Activation expirée | « erreur de génération », PDF perdu | téléchargement immédiat, sans erreur en console |
| Feuille jamais ouverte | bouton figé sur « Génération… » | téléchargement après 20 s |
| Feuille fermée par l’utilisateur | « erreur de génération » | rien n’est imposé |
| Erreur inattendue | message muet | une ligne console.warn avec le nom de l’erreur |
Un second échec vu en recette n’a pas été reproduit sur deux jeux de données : il n’est pas corrigé, mais le message affiché porte désormais sa cause.
Lessons learned
navigator.shareexige une activation utilisateur récente. Un traitement long entre le clic et l’appel la consomme, etshare()rejette avecNotAllowedError.canShare()teste la capacité, pas le geste.navigator.userActivation.isActivedit si l’appel a encore une chance d’aboutir.navigator.sharepeut ne jamais se résoudre. Une course avec un minuteur borne l’attente et garantit que le repli est atteint.- Un
catchunique sur plusieurs étapes ment sur l’étape fautive. Journaliser la cause, et séparer génération et remise. AbortErrorn’est pas un échec. L’utilisateur a fermé la feuille : ne rien lui imposer, contrairement aux autres erreurs.