Une application métier que je développe propose un carnet de notes manuscrites sur tablette. Ses utilisateurs travaillent sur le terrain, souvent là où le réseau est mauvais ou absent, et ils écrivent au stylet comme sur un bloc papier. Deux exigences qui réclament d’ordinaire une application native.

Je ne suis pas parti sur du natif. Une PWA (une page web installable, servie par un Rails classique) fait le travail, à condition de respecter trois réalités de l’appareil : il n’y a pas toujours de réseau, le stylet est un vrai stylet, et il crache des données beaucoup plus vite qu’on ne l’imagine. Chacune cache un piège que les tutoriels ne mentionnent pas.

La mise à jour qui efface les notes hors ligne

Le offline-first repose sur un service worker qui met les pages en cache et les ressert quand le réseau tombe. Le schéma est connu. On nomme un cache, on y stocke les pages consultées, et au déploiement suivant on renomme le cache pour invalider l’ancien contenu.

C’est exactement ce renommage qui crée une panne silencieuse. Quand on passe carnet-v2 à v3, l’événement activate du nouveau service worker supprime l’ancien cache. Tout son contenu part avec, y compris les notes que l’utilisateur avait déjà consultées hors ligne. Résultat, quelqu’un installe la mise à jour le matin au dépôt, part sur le terrain, perd le réseau, ouvre ses notes… et tombe sur la page « pas de connexion ». Ses données existaient, on venait juste de les jeter.

Deux déroulés du même déploiement. Sans migration, activate supprime l'ancien cache et l'utilisateur parti sur le terrain tombe sur la page « pas de connexion ». Avec migration, install recopie les entrées dans le nouveau cache et les notes restent lisibles hors réseau.
Le renommage du cache est ce qui invalide l'ancien contenu, et c'est aussi ce qui jette les notes déjà consultées.

Le correctif est de migrer les entrées de l’ancien cache vers le nouveau avant que activate ne fasse le ménage :

self.addEventListener("install", (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(CACHE_NAME)
    // Reprendre les entrées des caches précédents avant qu'`activate`
    // ne les purge, sinon renommer CACHE_NAME efface les pages /admin/notes
    // déjà en cache, et l'utilisateur qui passe hors ligne juste après la
    // mise à jour atterrit sur une page d'erreur sans issue.
    for (const name of await caches.keys()) {
      if (name === CACHE_NAME) continue
      const previous = await caches.open(name)
      for (const request of await previous.keys()) {
        if (await cache.match(request)) continue
        const response = await previous.match(request)
        if (response) await cache.put(request, response)
      }
    }
    await cache.add(OFFLINE_URL) // la page offline, elle, est toujours rafraîchie
  })())
  self.skipWaiting()
})

Dans la foulée, la page offline cesse d’être un cul-de-sac : si les notes sont en cache, un script la fait rebondir dessus au lieu d’annoncer un échec.

Le stylet qui écrit « trop épais »

Pour le rendu du trait, j’utilise perfect-freehand, qui transforme une suite de points en un contour lissé à largeur variable. Par défaut, la librairie simule la pression à partir de la vitesse du geste : on va vite, le trait s’affine ; on va lentement, il s’épaissit.

Excellent pour un dessin. Catastrophique pour de l’écriture manuscrite. Quand on écrit, on va lentement et avec application, exactement le régime où la simulation gonfle le trait. Résultat, des lettres pâteuses qui ne ressemblent pas à ce que la main fait sur l’écran.

La tablette a un vrai stylet, qui rapporte une vraie pression via PointerEvent.pressure. La bonne réponse est donc d’éteindre la simulation et de n’utiliser que la pression matérielle quand elle existe :

export const FREEHAND_PEN_OPTS = Object.freeze({
  smoothing: 0.45,
  streamline: 0.2,
  // La largeur vient de la pression réelle du stylet, jamais de la vitesse :
  // la simulation épaississait le trait aux vitesses lentes de l'écriture.
  simulatePressure: false,
  // Peu de modulation : à forte pression, le trait redevenait pâteux.
  thinning: 0.2,
  // Sans ça, le lissage rattrape aussi le dernier point. Voir plus bas.
  last: true,
})

Avec une subtilité : PointerEvent.pressure vaut 0.5 quand l’appareil ne rapporte rien, souris et doigt compris. On détecte donc une fois par tracé si une valeur sort de ce 0.5 constant, et la largeur ne varie que dans ce cas.

Le trait qui ne colle pas à la pointe

Le symptôme était difficile à qualifier. Pas de lenteur visible, pas d’à-coup franc, seulement l’impression que le trait suivait le stylet au lieu de sortir de sa pointe. J’ai donc mesuré avant de toucher au code, avec deux scripts qui rejouent des tracés réels, l’un pour l’écart géométrique entre la pointe et le trait, l’autre pour le coût du pipeline dans Chromium. Deux causes sont sorties.

La pointe était filtrée. L’option streamline lisse chaque point vers le précédent, et sans last: true elle lisse aussi le dernier, celui qui est censé se trouver sous le stylet. Sur 67 tracés réels, 3497 échantillons, la pointe rendue traînait de 6,1 px en moyenne et jusqu’à 75,8 px sur les gestes rapides. Un booléen ramène l’écart à zéro et ne laisse le lissage qu’au sillage.

Le thread gelait au pire moment. Chaque pointerup programmait un instantané bitmap de la note en requestIdleCallback. Comme la lettre suivante arrive une centaine de millisecondes plus tard, le rappel tombait en plein tracé : recalcul de tous les contours, remplissage d’un canvas 2D et encodage WebP synchrone. Le trait se figeait de 150 à 950 ms avant de rattraper le stylet d’un coup.

Le correctif ne doit rien à un algorithme. L’instantané attend une pause d’une seconde et demie, ne part jamais pendant un geste et encode hors du thread principal ; le trait en cours a quitté le SVG des tracés persistés, où sa réécriture à chaque image faisait recalculer toute la scène, pour un canvas dédié en desynchronized: true. La pire image pendant l’écriture est passée de 199 à 34 ms sur une note légère, et de 935 à 21 ms sur une note de 370 tracés.

Reste le débit brut. À 500 Hz, la plupart des échantillons tombent à moins d’un pixel du précédent, sans ajouter de détail visible. Un filtrage par distance en espace écran les écarte.

const dx = ev.clientX - this.lastSampleClient.x
const dy = ev.clientY - this.lastSampleClient.y
if (dx * dx + dy * dy < MIN_SAMPLE_DIST_SQ) continue // < 1,2 px : on jette
this.lastSampleClient = { x: ev.clientX, y: ev.clientY }

La distance se mesure en pixels écran et non en coordonnées document, pour que la densité tienne à tous les zooms, et on compare son carré pour éviter une racine par point. Le filtre dédoublonne au passage les pointerrawupdate et pointermove qui décrivent le même point.

Le même tracé présenté deux fois. À gauche, les 96 échantillons bruts du stylet se chevauchent presque tous à moins de 1,2 pixel. À droite, 17 points sont conservés et le contour rendu est identique.
Les échantillons jetés ne portaient aucun détail visible, seulement du calcul par image.

getCoalescedEvents() et getPredictedEvents() étaient en place bien avant ce diagnostic et n’y étaient pour rien. La liste des bonnes pratiques était cochée, et le trait décrochait quand même.

Ce que j’en retiens

Aucun de ces correctifs n’est sophistiqué : une boucle, deux booléens, une comparaison de distances. Ce qui coûte, c’est d’identifier le bon problème. Le cache qui s’efface demande une vraie mauvaise connexion, le trait pâteux un vrai stylet en main, la pointe qui décroche un instrument de mesure. Rien de tout ça ne se manifeste sur un MacBook en Wi-Fi pendant le développement.

Le navigateur est largement capable d’une expérience proche du natif, sans le coût d’une application à maintenir, à signer et à faire passer par un store. Ce qu’il demande en échange, c’est un banc de test qui ressemble au terrain : une vraie tablette, un vrai stylet, et le mode avion.

Si ce sujet vous parle

Carnet hors ligne, synchronisation qui survit à une connexion pourrie, application terrain qui doit tenir sur un iPad qui s’endort : c’est exactement le genre de friction que je débloque chez SXN Labs. Si vous avez un usage métier coincé entre « il faudrait une app native » et « le web n’y arrivera pas », on peut regarder ce que ça demande vraiment.