# Actualización de la PWA: que una versión nueva no corte un envío

- **Fecha:** 2026-10-01
- **Rama:** `fix/pwa-actualizacion-segura`
- **Estado:** implementado en la rama `fix/pwa-actualizacion-segura`; falta PR y despliegue.
- **Origen:** seguimiento de PWA-002 (`2026-09-28-senal-debil-design.md`, en producción desde main `7d68a58`, PR #268). Allí quedó anotado: "la actualización del SW recarga en pleno envío; desplegar fuera del horario de cobro".

## Problema

Tras un deploy, la PWA se recarga sola en cuanto vuelve a primer plano, haga lo que haga el cobrador. Leído sobre `7d68a58`:

1. `main.js` llama a `registration.update()` en cada `visibilitychange` → `visible`. Si hay versión nueva, `onNeedRefresh` llama a `updateSW(true)`.
2. Quitar esa llamada no alcanza. `pwa-sw.js` hace `self.skipWaiting()` en `install` y `clients.claim()` en `activate`: el SW nuevo se activa solo y toma la página. `update()` corre más de 60 s después del registro, así que workbox-window lo trata como "externo"; el `installed` de `vite-plugin-pwa` (`registerType: 'prompt'`) instala entonces su propio `controlling → window.location.reload()`, que salta con el `clients.claim()`.
3. Retrasar solo la recarga tampoco: al activarse, el precache del SW nuevo borra los chunks con hash de la versión anterior, y el deploy reemplaza `public/build`. Las 27 vistas se cargan con `import()` diferido: la siguiente navegación de la página vieja falla al pedir su chunk y `router.onError` hace `window.location.assign`, otra recarga sin guardia.
4. Una recarga en mal momento pierde el toque. Cobro, visita "No paga" y gasto pasan por `enviarOEncolar` (`stores/sync.js`), que guarda la fila en IndexedDB antes del POST directo; pero las pantallas esperan hasta 6 s de GPS **antes** de armar la fila. Una recarga en esa espera deja el cobro sin registrar en ninguna parte.
5. Toda recarga cuesta un login: el token vive solo en memoria (`services/api.js`, decisión anti-XSS) y entrar exige señal. Con señal débil, el cobrador puede quedar fuera de la app en plena ruta.

## Principio

**La versión nueva espera a que la app no esté haciendo nada que no se pueda cortar, y solo se aplica sola cuando recargar no le cuesta nada al usuario.**

- "Nada en curso": ninguna pantalla en pleno envío (`submitting`, incluida la espera del GPS), ninguna llamada a `enviarOEncolar` corriendo y ninguna ronda de sincronización (`syncing`).
- "No cuesta nada": no hay token en memoria **y** la app está en el login (rutas `meta.guest`): recién abierta, tras un 401 o el vencimiento de 24 h, o tras cerrar sesión. Hay que entrar de todos modos. "Sin token" no basta: el usuario sigue guardado y la app se puede usar desde la caché, con un formulario a medio escribir.
- Y es la única ventana de la app: `skipWaiting()` cambia la versión para todas, y otra pestaña puede tener una sesión.
- Con sesión viva, la app avisa y el usuario decide cuándo (con señal: después hay que volver a entrar).

## Diseño

### 1. El SW nuevo espera (`pwa-sw.js`)

- `install` deja de llamar a `self.skipWaiting()`. El SW nuevo queda en `waiting` hasta recibir `SKIP_WAITING` (el manejador de `message` ya existe) o hasta que no quede ninguna página con el viejo (la app cerrada del todo: el navegador lo activa solo).
- Mientras espera, el SW viejo sigue sirviendo su precache completo, con los chunks de su versión: la página vieja funciona entera, también sin señal.
- `activate` mantiene `clients.claim()`. La primera instalación no cambia: sin SW activo no hay espera.
- **`SKIP_WAITING` con `soloSiUnica: true`** (el camino automático): el SW solo se activa si la ventana que lo pidió (`event.source`) es la única de la app (`clients.matchAll({ type: 'window', includeUncontrolled: true })` con ruta `/pwa…`; el panel `/admin` no recarga con el cambio y no cuenta). Lo vuelve a mirar cada segundo hasta 5 s antes de negarse: la página pide la activación apenas carga, y la que se acaba de dejar todavía figura un instante como ventana (visto en el E2E: se negaba y al rato quedaba una sola). Si en esos segundos se cierra la que lo pidió, o queda otra sola, no se activa: la que sigue abierta puede tener una sesión. Sin `soloSiUnica` (el toque en "Actualizar"), se activa sin condiciones.

### 2. Lo que no se puede cortar (`composables/operacionesEnCurso.js`, nuevo)

Un contador reactivo de módulo, como `useToast`:

- `conOperacionEnCurso(fn)` envuelve una función async: cuenta desde la llamada hasta que su promesa termina, resuelva o rechace, aunque la pantalla se desmonte antes (el cobrador puede volver atrás durante "Procesando..." y el envío sigue).
- `retenerMientras(fuente)`, para un componente: retiene mientras el getter sea verdadero y suelta al desmontarse.
- `hayOperacionEnCurso` (computed).
- **Una operación sigue contando 8 s después de terminar** (`REPOSO_MS`). El envío termina justo cuando aparece su resultado: el recibo "Pago Registrado", "Gasto registrado" antes del regreso automático, o un aviso de incertidumbre de 8 s ("puede que sí haya quedado; si lo repites, podría quedar dos veces"). Una recarga en ese instante se lo borra, y un cobrador que no vio la confirmación lo vuelve a registrar: un duplicado. 8 s es lo que duran esos avisos.

Se envuelven:

| Dónde | Qué |
|---|---|
| `stores/sync.js` | `enviarOEncolar` |
| `PaymentView.vue` | `submit` (incluye la espera del GPS) |
| `NoPaymentModal.vue` | el envío (incluye la espera del GPS) |
| `ExpenseCreateView.vue` | `submit` |
| `CreditCreateView.vue` | `handleSubmit` |
| `PartnerTransactionView.vue` | `submit` |
| `ClientCreateView.vue`, `ClientEditView.vue` | `submitClient`, `submitEdit` |
| `LoginView.vue` | `handleLogin`, y `retenerMientras` mientras el formulario tenga texto |
| `stores/auth.js` | `logout`, sin reposo |
| `components/credits/PaymentHistory.vue` | `confirmVoid` (anular un pago) |
| `stores/approvals.js` | `approve`, `reject` |
| `CreditOrderView.vue` | `save` (reordenar la ruta) |
| `stores/settings.js` | `updateSettings` |

Las últimas cuatro son escrituras con sesión: solo importan si el usuario toca "Actualizar" mientras salen, pero una recarga ahí deja la duda de si quedaron.

El formulario de login con texto cuenta como "en curso" para que una versión que termina de instalarse mientras el cobrador escribe la contraseña no le borre lo escrito: esa versión se ofrece con el aviso después de entrar.

`logout` cuenta porque `endSession()` borra el token **antes** de `await db.clearCaches()`: sin token la versión se aplicaría sola y la recarga cortaría el borrado de la caché de lectura (el que protege al siguiente usuario de un teléfono compartido, PWA-011) y el aviso de pendientes conservados. Ni `logout` ni `retenerMientras` llevan reposo (`reposoMs: 0`): no dejan un resultado que leer, y el reposo dejaría empezar a escribir en el login y bloquear la actualización gratuita.

### 3. Quién decide (`services/actualizacion.js`, nuevo)

Reemplaza el bloque de `registerSW` de `main.js` (que pasa a llamar a `iniciarActualizaciones(router)` después de `app.use(pinia)` y `app.use(router)`).

- **Estado:** `hayVersionNueva` (hay un SW en `waiting`), `pedida` (el usuario tocó "Actualizar") y `libre` = `!hayOperacionEnCurso && !syncStore.syncing`.
- **`onNeedRefresh`** marca `hayVersionNueva` y llama a `intentar()`. Lo dispara el plugin en los tres casos: versión encontrada por `update()` (externa), por la verificación del navegador al cargar, y un SW que ya esperaba antes de registrar (`wasWaitingBeforeRegister`, el caso de la carga completa tras un 401). Puede llegar dos veces; es idempotente.
- **Además, el módulo mira la registración** (`vigilarRegistro`): `waiting` al registrar, y `updatefound` → `statechange` después. Si la versión todavía se instalaba cuando la página registró el SW, su `updatefound` ya pasó en la página anterior, workbox-window no la sigue y `onNeedRefresh` nunca llega. Con `waiting` pero sin SW activo no cuenta: es la primera instalación, que no espera.
- **`intentar()`:** si hay versión nueva, no se está aplicando y está `libre`:
  - sin token y en una ruta `meta.guest` (el login): se aplica con `soloSiUnica: true` (ver 1);
  - si el usuario la pidió y hay señal: se aplica sin condición.
  
  Si no, espera: un `watch` sobre `libre`, el token, `hayVersionNueva`, `pedida`, la señal y la ruta vuelve a llamarla, y también la vuelta a primer plano.
- **Sin señal, lo pedido se anula** (`pedida = false` en el evento `offline`): con la recarga ocurriendo igual al terminar el envío, el cobrador quedaría fuera de la app sin poder volver a entrar. El aviso se vuelve a ofrecer con señal.
- **`aplicar()`:** sin registración todavía, no hace nada: `onRegisteredSW` vuelve a llamar a `intentar()`. Es el caso de un SW que ya esperaba al cargar: workbox-window lo avisa en una microtarea dentro de `register()`, **antes** de que el plugin entregue la registración, y descartarlo ahí perdería justo la actualización gratuita tras un 401. Si la registración ya no tiene `waiting`, limpia el estado. Si lo tiene, marca `aplicando` y manda `SKIP_WAITING` directo al SW en espera (`registro.waiting.postMessage`, con `soloSiUnica` según el camino).
- **Tope de 10 s para `aplicando`:** si `SKIP_WAITING` no surte efecto (el SW se negó por otra ventana, o el mensaje se perdió), se vuelve a poder intentar. Lo pedido por el usuario se reintenta al vencer el tope; el camino automático, en la próxima ocasión (volver a primer plano, cambiar de ruta, recuperar señal).
- **La recarga la decide `controllerchange`, no el plugin.** `onNeedReload` queda vacío a propósito: el plugin solo recarga si la página ya tenía SW al registrarse (`isUpdate`), y una página que arrancó sin SW (primera instalación, recarga forzada) se quedaría con código viejo bajo el SW nuevo. El módulo escucha `navigator.serviceWorker` `controllerchange` y recarga si se estaba aplicando **o** si ya había un controlador antes (otra pestaña aplicó la versión). El `clients.claim()` de la primera instalación no tiene controlador anterior: no recarga.
- **`recargarCuandoSeaSeguro(destino?)`:** recarga en cuanto esté `libre` (`location.assign(destino)` o `location.reload()`), una sola vez. Lo usan `controllerchange` y `router.onError` (ver 5).
- **`update()` al volver a primer plano** se mantiene (iOS no re-verifica el SW al reabrir desde segundo plano) y se muda a este módulo, con `.catch(() => {})`: sin señal `update()` rechaza, y sin el `catch` cada vuelta a primer plano sin señal mandaba un rechazo no atrapado a Sentry (ya pasaba antes de este cambio).

### 4. El aviso (`components/ui/AvisoVersionNueva.vue`, nuevo)

- Se ve con versión nueva, token en memoria y señal (`navigator.onLine`; sin señal no se podría volver a entrar). Sin token no hace falta: la versión se aplica sola en el login.
- Una franja delgada, dentro del flujo de la página (no flotante, no tapa nada):
  - "Hay una versión nueva" · "Al actualizar vuelves a iniciar sesión." · botón **Actualizar**.
  - Tocado con algo en curso: "Se actualiza al terminar el envío" y el botón queda apagado.
  - El botón tiene al menos 44 px de alto (`min-h-[44px]`), el mínimo de un blanco táctil.
- Va en `PwaHeader` (bajo la fila del título, así aparece en todas las vistas que lo usan) y en `DashboardHeader` (la cabecera propia de `HomeView`, la única vista sin `PwaHeader`), bajo el saludo: entre la cabecera y las tarjetas lo taparía el margen negativo con que estas se meten bajo su borde.

### 5. `router.onError`

El chunk que no carga con señal pasa por `recargarCuandoSeaSeguro(to.fullPath)` en vez de `window.location.assign` directo. Con el SW nuevo en espera ya no debería pasar tras un deploy; queda para los demás casos (SW bloqueado o no soportado, otra pestaña que aplicó la versión) sin cortar un envío.

## Casos borde

- **Sin token fuera del login** (la app recargada sin señal y usada desde la caché): no se aplica sola, ni hay aviso (que pide token). Se aplica al llegar al login: el 401 de la primera llamada con señal es una carga completa de `/pwa/login`, y el SW que esperaba se aplica al arrancar.
- **Dos pestañas de la app:** la que está en el login sin token no la aplica mientras la otra siga abierta, tenga o no sesión (el SW no sabe cuál la tiene). Se ofrece con el aviso al entrar. Tocar "Actualizar" en una sí recarga la otra: lo pidió el usuario.
- **Carrera:** si se toca "Confirmar" entre `SKIP_WAITING` y `controllerchange`, la recarga espera a que termine. Si en ese lapso una navegación pide un chunk viejo que el SW nuevo ya borró, `router.onError` pasa por la misma puerta.
- **Dos deploys seguidos sin aplicar:** el SW en espera queda `redundant` y el más nuevo pasa a esperar; `onNeedRefresh` vuelve a llegar.
- **Cuánto puede durar la versión vieja:** hasta que el usuario toque "Actualizar", cierre la app del todo, cierre sesión o venza el token (24 h, `PWA_TOKEN_EXPIRATION`). Mientras tanto habla con el servidor nuevo, como hoy en la ventana entre el deploy y la vuelta a primer plano: los cambios de API siguen teniendo que ser compatibles con el bundle anterior, como ya exigen PWA-001 y PWA-002.
- **`ProfileView`** ya marca la versión como desactualizada cuando el SW activo no es el del servidor; no cambia.

## Pruebas

**E2E nuevo `tests/e2e/actualizacion-sw.spec.js`** (solo chromium, un login de cobrador):

- `serviceWorkers: 'allow'` solo en este spec; el resto de la suite sigue bloqueándolo.
- Simula un deploy reescribiendo `public/pwa-sw.js` en disco (el mismo SW más un manejador de mensaje que responde una marca de versión) y llamando a `registration.update()`. Playwright no intercepta la descarga del script del SW (probado: `context.route` no la ve), y el archivo está en `.gitignore` y lo sirven tanto `artisan serve` como el nginx del CI. El spec lo restaura al terminar.
- La app arranca ya controlada por el SW del build (como en producción): se carga el login, se espera a que el SW controle la página y recién entonces se entra.
- Con sesión: llega v2 → queda en `waiting`, la página **no** recarga y aparece el aviso.
- Una segunda pestaña en el mismo contexto, en el login sin token: no la aplica, y la pestaña con sesión no recarga. Cerrada esa pestaña, pasados los 5 s en que el SW vuelve a mirar, tampoco: la que queda sola no es la que lo pidió.
- Con el GPS colgado (un `getCurrentPosition` que nunca responde: la pantalla espera los 6 s del tope), se toca "Confirmar Pago" y, durante "Procesando...", "Actualizar". Se corta la señal y vuelve: lo pedido se anula, el cobro sale, y pasado el reposo la página sigue sin recargar.
- Otro cobro, otra vez "Actualizar" durante "Procesando..." → "Se actualiza al terminar el envío", sin recarga. El recibo "Pago Registrado" se ve sin recarga, y recién después la página recarga con v2 activo.
- Sin token, fuera del login (la recarga dejó la app en el recibo): llega v3 → no recarga. Una carga completa de `/pwa/login` (como la del interceptor de 401) la aplica al arrancar. Navegar no activa por sí solo un SW en espera.
- En el login: con texto en el formulario llega v4 → no recarga. Se vacía el formulario → recarga sola, con v4 activo.
- Si una versión no llega a mandar, el mensaje dice qué había en la registración y qué ventanas veía el SW.
- Cada garantía se comprobó en las dos direcciones: el test falla si se quita el envoltorio de la pantalla de pago (la recarga corta la espera del GPS), si el reposo es 0 (la recarga borra el recibo al aparecer), sin la retención del login (borra lo escrito), con la carrera de la registración, sin `soloSiUnica` (la segunda pestaña la aplica), sin comprobar que la ventana única sea la que lo pidió (cerrada la otra pestaña, la que tiene sesión recarga), sin la condición de ruta de invitado (recarga en el recibo) y sin anular lo pedido al perder la señal (el aviso no vuelve a ofrecerse).
- La segunda mirada del SW (hasta 5 s) arregla una carrera y no tiene un test determinista: sin ella, el paso de la carga completa del login falló en 4 de 7 corridas (el SW contaba todavía la página recién dejada, `ventanas=[/pwa/login visible]` al final); con ella, 0 de 9.
- `logout` no tiene test propio: el borrado de la caché suele terminar antes que la activación del SW, así que un test pasaría igual con y sin el arreglo.
- Falla con el código de hoy: v2 nunca queda en `waiting` (`skipWaiting()` en `install` lo activa al instante y toma la página). En producción eso termina en la recarga del plugin cuando la versión llega más de 60 s después del registro; el test no espera esos 60 s, prueba la causa.

**Antes de pushear:** `npm run build`, la suite E2E completa (chromium) y, como en el CI, PHPStan nivel 5, Pint y la suite de PHP (este cambio no toca PHP).

## Despliegue

- Solo frontend: `public/build` y `public/pwa-sw.js`. Sin migraciones.
- **Fuera del horario de cobro, una última vez.** Los teléfonos con el bundle de PWA-002 corren el `main.js` viejo, que manda `SKIP_WAITING` apenas detecta el SW nuevo: esa recarga no tiene guardia. Desde el deploy siguiente ya rige la espera.
- Reversa: volver al bundle anterior restaura el comportamiento de hoy. No cambia nada persistente.
- CHANGELOG.

## Fuera de alcance

- **Conservar el token a través de la recarga** (pasarlo por `sessionStorage` solo para esa recarga): haría invisible la actualización, pero cambia la decisión anti-XSS de "token solo en memoria". Necesitaría su propio diseño.
- **La recarga del interceptor de 401** (`services/api.js`): la sesión ya se perdió; desde PWA-002 la fila de campo se guarda antes de enviar.
- **PWA-003:** la caché del SW que sirve datos viejos como nuevos.
