mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(B182): PWA iOS — heal do zoom preso do visual viewport no standalone
Motor client-side (standalone-iOS only, no-op em aba/desktop): heal imediato no install + launch/bfcache/resume/pós-teclado/resize, scrollTo(0,0) + re-measure nudge, retry ≤3×250ms por episódio, pinça preservada (janela renovada por gesture*/touch ≥2), debug global para verificação em aparelho, fallback maximum-scale off. 40 testes unit (jsdom) pinam detecção, guards, retry e teardown.
This commit is contained in:
parent
44f4f3b96a
commit
dff940681d
5 changed files with 923 additions and 0 deletions
86
docs/plans/pwa-ios-zoom-preso-bordas-cortadas-impl.md
Normal file
86
docs/plans/pwa-ios-zoom-preso-bordas-cortadas-impl.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Impl: PWA iOS: tela ampliada e com as bordas cortadas desde o launch (zoom preso)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-09
|
||||
Issue: #500
|
||||
Intenção: docs/plans/pwa-ios-zoom-preso-bordas-cortadas.md
|
||||
Appetite restante: ~0,5–1,5 dia eng (herdado; inclui verificação em aparelho)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** ao abrir/retomar o PWA `/campanha` no iPhone, escala 100% e nenhuma borda cortada; após teclado, a tela volta sozinha; pinça continua funcionando; zero regressão em aba/desktop.
|
||||
- **O que NÃO negociar:** pinça do usuário (anti-goal de acessibilidade); sem hack de tela cheia que esconda conteúdo; sem redesenho; bloqueio de escala só como fallback documentado (decisão do gate: A ou B, nunca C).
|
||||
- **O que reavaliar:** a hipótese da intenção apontava "config de viewport / shell h-svh / statusBarStyle" como área provável. **Verificação no código (Next 15.4.11):** o meta viewport de hoje já é `width=device-width, initial-scale=1` **sem** `maximum-scale` nem `viewport-fit=cover` (default do App Router em `node_modules/next/dist/lib/metadata/default-metadata.js`; o layout exporta só `themeColor`), e o shell `h-svh`/`overflow-hidden` é coerente com o chrome que já trata safe-area. Não há política de escala que remover nem configuração que mude o sintoma: **não existe um "toggle" que conserte isso** — a causa é o visual viewport do standalone ficar com scale > 1 preso (família de bugs WebKit 237961 / regressões iOS 26), então a correção é **client-side: curar o estado preso**.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Mount em (campaign) root layout] --> B[Engine lib/campaignIosViewportHeal]
|
||||
B --> C[Gate standalone-iOS]
|
||||
C --> D{scale > 1.02?}
|
||||
D -->|sim + sem input focado + sem pinça recente| E[Heal: scrollTo 0,0]
|
||||
E --> F[Re-measure nudge: html height]
|
||||
F --> G[Retry <=3x 250ms]
|
||||
G --> H[Debug global __campaignIosViewportHeal]
|
||||
D -->|não| I[No-op]
|
||||
C -->|não| I
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (heal client-side do visual viewport, standalone-iOS only) | B (`maximum-scale=1`/`user-scalable=no` seletivo no standalone) | C (`interactive-widget=resizes-content`) | D (depender do B183) | E (mexer em `viewport-fit`/statusBarStyle) | F (reload forçado)
|
||||
|
||||
**Recomendação:** A — ataca o sintoma (scale preso) em todas as origens (launch, resume/bfcache, pós-teclado) com o menor conjunto de medidas, é standalone-only (zero risco em aba/desktop), mantém a pinça (guards) e é verificável no aparelho via debug global. É a técnica validada pela família de reports (cederhook 2026-07 — "heal via forced re-measure"; west-wind — scrollTo + 16px como trigger fix).
|
||||
|
||||
**Rejeitadas:**
|
||||
|
||||
- B — mata a pinça (anti-goal de produto); vira **fallback documentado, off por default** via constante única: se a verificação no aparelho mostrar que o heal não cura, um deploy com a flag ligada ativa o `maximum-scale` seletivo só no standalone (decisão de produto B do gate, tradeoff WCAG conhecido).
|
||||
- C — muda a semântica de layout do teclado do app inteiro (reflow do shell `h-svh` quando o teclado abre); risco alto de regressão de layout, não mira o zoom-de-launch.
|
||||
- D — o B183 elimina o _trigger_ de input (fonte < 16px), mas o sintoma do launch/resume é independente de input; o aceite do B182 inclui o launch. O B183 reduz a frequência; não substitui.
|
||||
- E — o meta de hoje não tem `maximum-scale` nem `cover`; não há política a ajustar; mexer no chrome (cover + status bar) altera métricas verticais de todas as telas sem tocar a causa do zoom.
|
||||
- F — reload brutal, perde estado de navegação; só para crash-loop de diagnóstico.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`campaignIosViewportHeal`** (`src/lib/campaignIosViewportHeal.ts`): motor profundo, testável (jsdom). Detecta standalone-iOS (`navigator.standalone === true` OU `matchMedia('(display-mode: standalone)')` + UA iOS), escuta `load`/`pageshow` (incl. `persisted`/bfcache)/`visibilitychange→visible`/`focusout`/`visualViewport.resize`, decide heal (`visualViewport.scale > 1.02` && !editable focado && !pinça recente — `gesturestart/change/end` + touch ≥2, janela 800 ms renovada durante o gesto) e executa em camadas: `scrollTo(0,0)` → re-measure nudge (`html.style.height` = `innerHeight px`, restore — reflow genérico; **não** re-resolve `svh` sozinho, verificação em aparelho decide se precisa estender ao scroll container) → retry ≤3 × 250 ms com **orçamento por episódio** (launch/resume/bfcache/teclado/resize — um episódio não esfomeia o próximo). **Heal imediato no install** (load/pageshow podem ter ocorrido antes da hidratação do React). `history.scrollRestoration = 'manual'` no standalone (evita o iOS restaurar estado bfcache ampliado). Expõe `window.__campaignIosViewportHeal = { state, healNow() }` (verificação em aparelho sem UI). Constante `CAMPAIGN_IOS_HEAL_MAXIMUM_SCALE_FALLBACK = false` (fallback B, documentado; opção `maximumScaleFallback` no install).
|
||||
- **`CampaignIosViewportHeal`** (`src/components/campaign/shell/CampaignIosViewportHeal.tsx`): componente client que monta o motor em `useEffect` e retorna `null` — precedente: `RegisterServiceWorker`. `lib/` é client-safe (precedente: `campaignLastActedMunicipality`), então o motor pode viver lá.
|
||||
- **`src/app/(campaign)/layout.tsx`**: montar o componente como sibling de `RegisterServiceWorker` — cobre login + `(app)` inteiro. **Sem migration, sem Access, sem Consent.**
|
||||
- **UI:** Impeccable A — nenhuma UI nova; o único "afetado" visual é o usuário não ver mais a tela ampliada.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Motor + mount** — `src/lib/campaignIosViewportHeal.ts` + `CampaignIosViewportHeal.tsx` + layout. Tracer bullet: heal no launch.
|
||||
2. **Testes de domínio** — `tests/unit/campaignIosViewportHeal.unit.spec.ts` (jsdom): gate standalone (não-standalone = no-op), heal no launch (scale > 1 → scrollTo + nudge), skip com editable focado, skip com pinça recente, heal pós-teclado, retry com teto, debug global, fallback off.
|
||||
3. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` na entrega. Runbook de verificação em aparelho (checklist do debug global + B183) documentado no corpo do PR para o aceite de produto.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Polifill geral de teclado/keyboard-inset (fora de escopo da intenção).
|
||||
- Caçar a família inteira de bugs WebKit standalone — curamos o sintoma do nosso app, com fallback documentado.
|
||||
- Mudar `viewport-fit`/status bar/`interactive-widget` (opções rejeitadas E/C).
|
||||
- Ajuste de fontes dos inputs (é o B183; pode ser executado em paralelo).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Heal lutar contra pinça intencional do usuário** → guards: nunca heal com editable focado; janela de pinça renovada por `gesturestart/change/end` e touch ≥2 (cobre gestos longos); threshold 1.02 (zoom acidental de pinça costuma ser > 1.05). Após um zoom deliberado que termina, nenhum evento dispara novo heal — a pinça persiste.
|
||||
- **`scrollTo(0,0)` pode não surtir no shell `overflow-hidden`** → segunda camada (re-measure nudge) + heal imediato no install; a verificação em aparelho decide — o debug global permite testar `healNow()` sem deploy. **Gatilho:** se no aparelho o heal não curar, estender `healDocument` para togglar o overflow do scroll container do shell antes de ligar o fallback B.
|
||||
- **Regressão em aba/desktop** → gate standalone-iOS: no-op em qualquer outro contexto (o código nem roda `visualViewport` em não-standalone).
|
||||
- **iOS 26 muda comportamento** → heal é conservador (só age com scale > 1.02) e idempotente; fallback B documentado como último recurso.
|
||||
|
||||
## Verificação em aparelho (aceite de produto)
|
||||
|
||||
A suíte unit prova a fiação (detecção, guards, retry), não a cura no WebKit. O aceite é visual, no iPhone com o PWA instalado (standalone):
|
||||
|
||||
1. **Launch (cold open):** fechar o app, abrir → escala 100%, nenhuma borda cortada. Se abrir ampliado: `window.__campaignIosViewportHeal.healNow()` no console — se curar, o heal funcionou e o bug era o launch sem trigger (o immediate heal no install cobre).
|
||||
2. **Resume (bfcache):** abrir outro app, voltar → sem zoom.
|
||||
3. **Teclado:** tocar na omnibox de uma lista, digitar, fechar o teclado → tela volta sozinha. (O trigger de 16px é o B183 — neste item o heal cura o estado preso caso o auto-zoom ainda dispare.)
|
||||
4. **Pinça:** dar zoom por pinça → persiste (não é resetado); `state.lastSkippedReason === 'recent-pinch'` enquanto durar o gesto.
|
||||
5. **Aba/desktop:** `isIosSafariStandalone` false → módulo é no-op (`state.standaloneIos === false`); nenhuma mudança perceptível.
|
||||
|
||||
Se 1–3 falharem no aparelho: flip da flag `maximumScaleFallback` (decisão B) ou extensão do `healDocument` ao scroll container (gatilho acima).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (launch, resume, pós-teclado; pinça preservada; aba/desktop intactos)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem server-only em lib, sem UI nova, sem schema)
|
||||
- [ ] Testes de domínio: `tests/unit/campaignIosViewportHeal.unit.spec.ts`
|
||||
- [ ] Gates verdes: `gate:fast`, `tsc`, `lint` 0 warnings, `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm build`
|
||||
|
|
@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next'
|
|||
import { Inter } from 'next/font/google'
|
||||
import React from 'react'
|
||||
|
||||
import { CampaignIosViewportHeal } from '@/components/campaign/shell/CampaignIosViewportHeal'
|
||||
import { RegisterServiceWorker } from '@/components/campaign/shell/RegisterServiceWorker'
|
||||
import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa'
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export default function CampanhaRootLayout({ children }: { children: React.React
|
|||
<body className="bg-background bg-none text-foreground antialiased">
|
||||
{children}
|
||||
<RegisterServiceWorker />
|
||||
<CampaignIosViewportHeal />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
16
src/components/campaign/shell/CampaignIosViewportHeal.tsx
Normal file
16
src/components/campaign/shell/CampaignIosViewportHeal.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { installCampaignIosViewportHeal } from '@/lib/campaignIosViewportHeal'
|
||||
|
||||
export const CampaignIosViewportHeal = () => {
|
||||
useEffect(() => {
|
||||
const teardown = installCampaignIosViewportHeal(window)
|
||||
return () => {
|
||||
teardown()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
309
src/lib/campaignIosViewportHeal.ts
Normal file
309
src/lib/campaignIosViewportHeal.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* iOS standalone PWA viewport heal (B182).
|
||||
*
|
||||
* WebKit keeps the standalone visual viewport stuck at scale > 1 — at launch,
|
||||
* on bfcache resume, or after the keyboard auto-zoom fails to restore
|
||||
* (WebKit bug 237961 family / iOS 26 regressions). There is no viewport
|
||||
* policy that prevents it (the meta is already `width=device-width,
|
||||
* initial-scale=1`), so the fix is client-side: detect the stuck scale and
|
||||
* nudge WebKit back to 100%.
|
||||
*
|
||||
* Fail-safe by design: everything gates on iOS standalone, no-ops when the
|
||||
* scale is already 1, never heals while an editable is focused or a pinch is
|
||||
* recent (user zoom is a product anti-goal), and never throws.
|
||||
*/
|
||||
|
||||
const CAMPAIGN_IOS_VIEWPORT_HEAL_MIN_SCALE = 1.02
|
||||
export const CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS = 3
|
||||
export const CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS = 250
|
||||
export const CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS = 800
|
||||
export const CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS = 350
|
||||
|
||||
/**
|
||||
* Documented fallback (product decision B): if the on-device verification
|
||||
* proves the heal cannot cure the stuck state on current iOS, flipping this
|
||||
* to true enables a selective `maximum-scale=1` meta rewrite — standalone
|
||||
* only, with the known WCAG tradeoff (pinch zoom dies in the PWA).
|
||||
*/
|
||||
const CAMPAIGN_IOS_VIEWPORT_HEAL_MAXIMUM_SCALE_FALLBACK = false
|
||||
|
||||
export const CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL = '__campaignIosViewportHeal'
|
||||
|
||||
type CampaignIosViewportHealState = {
|
||||
mounted: boolean
|
||||
standaloneIos: boolean
|
||||
lastScale: number
|
||||
healedCount: number
|
||||
lastHealAt: number | null
|
||||
lastHealReason: string | null
|
||||
lastSkippedReason: string | null
|
||||
}
|
||||
|
||||
type CampaignIosViewportHealDebug = {
|
||||
state: CampaignIosViewportHealState
|
||||
healNow: (reason?: string) => boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?: CampaignIosViewportHealDebug
|
||||
}
|
||||
}
|
||||
|
||||
type HealReason = 'launch' | 'bfcache' | 'resume' | 'keyboard' | 'resize' | 'manual'
|
||||
|
||||
const isEditableElement = (element: Element | null): boolean => {
|
||||
if (!element) return false
|
||||
const tag = element.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
||||
return (element as HTMLElement).isContentEditable === true
|
||||
}
|
||||
|
||||
export const isIosSafariStandalone = (win: Window): boolean => {
|
||||
const nav = win.navigator
|
||||
if ((nav as unknown as { standalone?: boolean }).standalone === true) return true
|
||||
const isIos =
|
||||
/iP(hone|ad|od)/.test(nav.userAgent) || (nav.platform === 'MacIntel' && nav.maxTouchPoints > 1)
|
||||
if (!isIos) return false
|
||||
try {
|
||||
return win.matchMedia('(display-mode: standalone)').matches
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const getVisualViewportScale = (win: Window): number => {
|
||||
const visualViewport = win.visualViewport
|
||||
if (!visualViewport || typeof visualViewport.scale !== 'number') return 1
|
||||
return visualViewport.scale
|
||||
}
|
||||
|
||||
export const shouldAttemptViewportHeal = (input: {
|
||||
scale: number
|
||||
editableFocused: boolean
|
||||
recentPinch: boolean
|
||||
minScale?: number
|
||||
}): boolean => {
|
||||
const minScale = input.minScale ?? CAMPAIGN_IOS_VIEWPORT_HEAL_MIN_SCALE
|
||||
return input.scale > minScale && !input.editableFocused && !input.recentPinch
|
||||
}
|
||||
|
||||
type InstallContext = {
|
||||
win: Window
|
||||
state: CampaignIosViewportHealState
|
||||
lastPinchAt: number
|
||||
keyboardWasOpen: boolean
|
||||
attemptsLeft: number
|
||||
retryTimer: number | null
|
||||
keyboardHealTimer: number | null
|
||||
healNow: (reason: HealReason) => boolean
|
||||
}
|
||||
|
||||
const healDocument = (win: Window): void => {
|
||||
try {
|
||||
win.scrollTo(0, 0)
|
||||
} catch {
|
||||
// scrollTo can be a stub (jsdom) or absent in odd webviews — never throw.
|
||||
}
|
||||
try {
|
||||
const docEl = win.document.documentElement
|
||||
const previousHeight = docEl.style.height
|
||||
docEl.style.height = `${win.innerHeight}px`
|
||||
void docEl.offsetHeight
|
||||
docEl.style.height = previousHeight
|
||||
} catch {
|
||||
// Re-measure nudge is best effort; scale may already be healed by scrollTo.
|
||||
}
|
||||
}
|
||||
|
||||
const createInstallContext = (win: Window, standaloneIos: boolean): InstallContext => {
|
||||
const state: CampaignIosViewportHealState = {
|
||||
mounted: true,
|
||||
standaloneIos,
|
||||
lastScale: getVisualViewportScale(win),
|
||||
healedCount: 0,
|
||||
lastHealAt: null,
|
||||
lastHealReason: null,
|
||||
lastSkippedReason: null,
|
||||
}
|
||||
const context: InstallContext = {
|
||||
win,
|
||||
state,
|
||||
lastPinchAt: 0,
|
||||
keyboardWasOpen: false,
|
||||
attemptsLeft: CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS,
|
||||
retryTimer: null,
|
||||
keyboardHealTimer: null,
|
||||
healNow: () => false,
|
||||
}
|
||||
context.healNow = (reason: HealReason): boolean => {
|
||||
if (!state.mounted) return false
|
||||
state.lastScale = getVisualViewportScale(win)
|
||||
const recentPinch =
|
||||
Date.now() - context.lastPinchAt < CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS
|
||||
const editableFocused = isEditableElement(win.document.activeElement)
|
||||
if (!shouldAttemptViewportHeal({ scale: state.lastScale, editableFocused, recentPinch })) {
|
||||
if (editableFocused) state.lastSkippedReason = 'editable-focused'
|
||||
else if (recentPinch) state.lastSkippedReason = 'recent-pinch'
|
||||
else state.lastSkippedReason = 'scale-ok'
|
||||
return false
|
||||
}
|
||||
healDocument(win)
|
||||
state.healedCount += 1
|
||||
state.lastHealAt = Date.now()
|
||||
state.lastHealReason = reason
|
||||
state.lastSkippedReason = null
|
||||
return true
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const scheduleRetry = (context: InstallContext, reason: HealReason): void => {
|
||||
if (context.retryTimer !== null) return
|
||||
context.retryTimer = context.win.setTimeout(() => {
|
||||
context.retryTimer = null
|
||||
if (context.attemptsLeft <= 0) return
|
||||
context.attemptsLeft -= 1
|
||||
if (context.healNow(reason)) scheduleRetry(context, reason)
|
||||
}, CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Each episode (launch, resume, bfcache, keyboard close, resize) gets a fresh
|
||||
* retry budget — a stuck episode may need several nudges, but one episode
|
||||
* must never starve the next.
|
||||
*/
|
||||
const attemptEpisodeHeal = (context: InstallContext, reason: HealReason): void => {
|
||||
context.attemptsLeft = CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS
|
||||
if (context.healNow(reason)) scheduleRetry(context, reason)
|
||||
}
|
||||
|
||||
export type CampaignIosViewportHealInstallOptions = {
|
||||
maximumScaleFallback?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the standalone iOS viewport heal; runs one launch heal immediately
|
||||
* (load/pageshow may have fired before React hydration) and then keeps
|
||||
* listening. Returns a teardown the React mount wires as the effect cleanup —
|
||||
* the campaign root never unmounts in the running app, so the cleanup only
|
||||
* fires in tests.
|
||||
*/
|
||||
export const installCampaignIosViewportHeal = (
|
||||
win: Window,
|
||||
options: CampaignIosViewportHealInstallOptions = {},
|
||||
): (() => void) => {
|
||||
const maximumScaleFallback =
|
||||
options.maximumScaleFallback ?? CAMPAIGN_IOS_VIEWPORT_HEAL_MAXIMUM_SCALE_FALLBACK
|
||||
const standaloneIos = isIosSafariStandalone(win)
|
||||
const context = createInstallContext(win, standaloneIos)
|
||||
const { state } = context
|
||||
|
||||
const debug: CampaignIosViewportHealDebug = {
|
||||
state,
|
||||
healNow: () => context.healNow('manual'),
|
||||
}
|
||||
|
||||
if (!standaloneIos) {
|
||||
win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] = debug
|
||||
return () => {
|
||||
state.mounted = false
|
||||
delete win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
}
|
||||
}
|
||||
|
||||
if (maximumScaleFallback) {
|
||||
const viewportMeta = win.document.querySelector('meta[name="viewport"]')
|
||||
viewportMeta?.setAttribute(
|
||||
'content',
|
||||
'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof win.history.scrollRestoration === 'string') {
|
||||
win.history.scrollRestoration = 'manual'
|
||||
}
|
||||
} catch {
|
||||
// Privacy modes can deny scrollRestoration writes.
|
||||
}
|
||||
|
||||
const markPinch = (): void => {
|
||||
context.lastPinchAt = Date.now()
|
||||
}
|
||||
|
||||
const onTouchStart = (event: TouchEvent): void => {
|
||||
if (event.touches && event.touches.length >= 2) markPinch()
|
||||
}
|
||||
|
||||
const onTouchMove = (event: TouchEvent): void => {
|
||||
if (event.touches && event.touches.length >= 2) markPinch()
|
||||
}
|
||||
|
||||
const onFocusIn = (event: FocusEvent): void => {
|
||||
if (isEditableElement(event.target as Element | null)) {
|
||||
context.keyboardWasOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
const onFocusOut = (): void => {
|
||||
if (!context.keyboardWasOpen) return
|
||||
context.keyboardWasOpen = false
|
||||
context.keyboardHealTimer = context.win.setTimeout(
|
||||
() => attemptEpisodeHeal(context, 'keyboard'),
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS,
|
||||
)
|
||||
}
|
||||
|
||||
const onVisualViewportResize = (): void => {
|
||||
attemptEpisodeHeal(context, 'resize')
|
||||
}
|
||||
|
||||
const onVisibilityChange = (): void => {
|
||||
if (win.document.visibilityState === 'visible') {
|
||||
attemptEpisodeHeal(context, 'resume')
|
||||
}
|
||||
}
|
||||
|
||||
const onPageshow = (event: PageTransitionEvent): void => {
|
||||
attemptEpisodeHeal(context, event.persisted ? 'bfcache' : 'launch')
|
||||
}
|
||||
|
||||
const onLoad = (): void => {
|
||||
attemptEpisodeHeal(context, 'launch')
|
||||
}
|
||||
|
||||
win.addEventListener('gesturestart', markPinch)
|
||||
win.addEventListener('gesturechange', markPinch)
|
||||
win.addEventListener('gestureend', markPinch)
|
||||
win.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
win.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
win.document.addEventListener('focusin', onFocusIn, true)
|
||||
win.document.addEventListener('focusout', onFocusOut, true)
|
||||
win.visualViewport?.addEventListener('resize', onVisualViewportResize)
|
||||
win.addEventListener('visibilitychange', onVisibilityChange)
|
||||
win.addEventListener('pageshow', onPageshow)
|
||||
win.addEventListener('load', onLoad)
|
||||
|
||||
attemptEpisodeHeal(context, 'launch')
|
||||
|
||||
win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL] = debug
|
||||
|
||||
return () => {
|
||||
state.mounted = false
|
||||
if (context.retryTimer !== null) win.clearTimeout(context.retryTimer)
|
||||
if (context.keyboardHealTimer !== null) win.clearTimeout(context.keyboardHealTimer)
|
||||
win.removeEventListener('gesturestart', markPinch)
|
||||
win.removeEventListener('gesturechange', markPinch)
|
||||
win.removeEventListener('gestureend', markPinch)
|
||||
win.removeEventListener('touchstart', onTouchStart)
|
||||
win.removeEventListener('touchmove', onTouchMove)
|
||||
win.document.removeEventListener('focusin', onFocusIn, true)
|
||||
win.document.removeEventListener('focusout', onFocusOut, true)
|
||||
win.visualViewport?.removeEventListener('resize', onVisualViewportResize)
|
||||
win.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
win.removeEventListener('pageshow', onPageshow)
|
||||
win.removeEventListener('load', onLoad)
|
||||
delete win[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
}
|
||||
}
|
||||
510
tests/unit/campaignIosViewportHeal.unit.spec.ts
Normal file
510
tests/unit/campaignIosViewportHeal.unit.spec.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL,
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS,
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS,
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS,
|
||||
CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS,
|
||||
getVisualViewportScale,
|
||||
installCampaignIosViewportHeal,
|
||||
isIosSafariStandalone,
|
||||
shouldAttemptViewportHeal,
|
||||
} from '@/lib/campaignIosViewportHeal'
|
||||
|
||||
const IOS_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const MAC_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||
|
||||
const stubNavigator = (partial: {
|
||||
userAgent?: string
|
||||
platform?: string
|
||||
maxTouchPoints?: number
|
||||
standalone?: boolean
|
||||
}): void => {
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) jsdom',
|
||||
platform: 'Linux',
|
||||
maxTouchPoints: 0,
|
||||
standalone: undefined,
|
||||
...partial,
|
||||
})
|
||||
}
|
||||
|
||||
const stubMatchMedia = (matches: boolean): void => {
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches }))
|
||||
}
|
||||
|
||||
const stubThrowingMatchMedia = (): void => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation(() => {
|
||||
throw new Error('unavailable')
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const stubStandaloneProperty = (value: boolean): void => {
|
||||
Object.defineProperty(window.navigator, 'standalone', {
|
||||
configurable: true,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
const stubScrollRestoration = (value: string): void => {
|
||||
Object.defineProperty(window.history, 'scrollRestoration', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
let viewportStub: {
|
||||
scale: number
|
||||
addEventListener: ReturnType<typeof vi.fn>
|
||||
removeEventListener: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const setScale = (scale: number): void => {
|
||||
viewportStub.scale = scale
|
||||
}
|
||||
|
||||
const clearVisualViewport = (): void => {
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const setVisible = (visible: boolean): void => {
|
||||
Object.defineProperty(window.document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: visible ? 'visible' : 'hidden',
|
||||
})
|
||||
}
|
||||
|
||||
const appendViewportMeta = (): HTMLMetaElement => {
|
||||
const viewportMeta = document.createElement('meta')
|
||||
viewportMeta.name = 'viewport'
|
||||
viewportMeta.content = 'width=device-width, initial-scale=1'
|
||||
document.head.append(viewportMeta)
|
||||
return viewportMeta
|
||||
}
|
||||
|
||||
let teardown: (() => void) | null = null
|
||||
|
||||
const installStandalone = (
|
||||
options?: Parameters<typeof installCampaignIosViewportHeal>[1],
|
||||
): void => {
|
||||
stubStandaloneProperty(true)
|
||||
teardown = installCampaignIosViewportHeal(window, options)
|
||||
}
|
||||
|
||||
const installNonStandalone = (): void => {
|
||||
teardown = installCampaignIosViewportHeal(window)
|
||||
}
|
||||
|
||||
const focusInput = (): HTMLInputElement => {
|
||||
const input = document.createElement('input')
|
||||
document.body.append(input)
|
||||
input.focus()
|
||||
return input
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
setVisible(true)
|
||||
document.body.innerHTML = ''
|
||||
document.head.innerHTML = ''
|
||||
viewportStub = {
|
||||
scale: 1,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: viewportStub,
|
||||
})
|
||||
stubStandaloneProperty(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
teardown?.()
|
||||
teardown = null
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
clearVisualViewport()
|
||||
setVisible(true)
|
||||
})
|
||||
|
||||
describe('isIosSafariStandalone', () => {
|
||||
it('returns true when navigator.standalone is set (classic iOS PWA)', () => {
|
||||
stubStandaloneProperty(true)
|
||||
expect(isIosSafariStandalone(window)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for iOS UA + standalone display mode', () => {
|
||||
stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' })
|
||||
stubMatchMedia(true)
|
||||
expect(isIosSafariStandalone(window)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for iOS UA without standalone display mode (Safari tab)', () => {
|
||||
stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' })
|
||||
stubMatchMedia(false)
|
||||
expect(isIosSafariStandalone(window)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a non-iOS UA even in standalone display mode', () => {
|
||||
stubNavigator({
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14) Mobile',
|
||||
platform: 'Linux',
|
||||
})
|
||||
stubMatchMedia(true)
|
||||
expect(isIosSafariStandalone(window)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for iPadOS (MacIntel platform + touch) in standalone', () => {
|
||||
stubNavigator({ userAgent: MAC_UA, platform: 'MacIntel', maxTouchPoints: 5 })
|
||||
stubMatchMedia(true)
|
||||
expect(isIosSafariStandalone(window)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a Mac without touch input', () => {
|
||||
stubNavigator({ userAgent: MAC_UA, platform: 'MacIntel', maxTouchPoints: 0 })
|
||||
stubMatchMedia(true)
|
||||
expect(isIosSafariStandalone(window)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when matchMedia throws', () => {
|
||||
stubNavigator({ userAgent: IOS_UA, platform: 'iPhone' })
|
||||
stubThrowingMatchMedia()
|
||||
expect(isIosSafariStandalone(window)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVisualViewportScale', () => {
|
||||
it('reads visualViewport.scale', () => {
|
||||
setScale(1.3)
|
||||
expect(getVisualViewportScale(window)).toBe(1.3)
|
||||
})
|
||||
|
||||
it('returns 1 when the VisualViewport API is missing', () => {
|
||||
clearVisualViewport()
|
||||
expect(getVisualViewportScale(window)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 1 when visualViewport.scale is not a number', () => {
|
||||
Object.defineProperty(window, 'visualViewport', {
|
||||
configurable: true,
|
||||
value: { scale: undefined },
|
||||
})
|
||||
expect(getVisualViewportScale(window)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAttemptViewportHeal', () => {
|
||||
it('heals when the scale is stuck and nothing blocks', () => {
|
||||
expect(
|
||||
shouldAttemptViewportHeal({ scale: 1.3, editableFocused: false, recentPinch: false }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not heal at scale 1', () => {
|
||||
expect(
|
||||
shouldAttemptViewportHeal({ scale: 1, editableFocused: false, recentPinch: false }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not heal while an editable is focused (keyboard open)', () => {
|
||||
expect(
|
||||
shouldAttemptViewportHeal({ scale: 1.3, editableFocused: true, recentPinch: false }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not heal right after a pinch (user zoom)', () => {
|
||||
expect(
|
||||
shouldAttemptViewportHeal({ scale: 1.3, editableFocused: false, recentPinch: true }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors an explicit minScale', () => {
|
||||
expect(
|
||||
shouldAttemptViewportHeal({
|
||||
scale: 1.01,
|
||||
editableFocused: false,
|
||||
recentPinch: false,
|
||||
minScale: 1,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCampaignIosViewportHeal — non-standalone contexts', () => {
|
||||
it('installs a no-op debug surface without wiring any heal', () => {
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
installNonStandalone()
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.standaloneIos).toBe(false)
|
||||
expect(debug?.state.mounted).toBe(true)
|
||||
|
||||
setScale(1.4)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||
expect(debug?.state.healedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps scrollRestoration untouched outside standalone', () => {
|
||||
stubScrollRestoration('auto')
|
||||
installNonStandalone()
|
||||
expect(window.history.scrollRestoration).toBe('auto')
|
||||
})
|
||||
|
||||
it('teardown removes the debug global', () => {
|
||||
installNonStandalone()
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeDefined()
|
||||
teardown?.()
|
||||
teardown = null
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCampaignIosViewportHeal — standalone', () => {
|
||||
it('opts out of history scroll restoration', () => {
|
||||
stubScrollRestoration('auto')
|
||||
installStandalone()
|
||||
expect(window.history.scrollRestoration).toBe('manual')
|
||||
})
|
||||
|
||||
it('heals immediately at install when already stuck (load/pageshow may predate hydration)', () => {
|
||||
setScale(1.3)
|
||||
installStandalone()
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
expect(debug?.state.lastHealReason).toBe('launch')
|
||||
})
|
||||
|
||||
it('heals on launch when the scale is stuck', () => {
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
installStandalone()
|
||||
setScale(1.4)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
expect(debug?.state.lastHealReason).toBe('launch')
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(0, 0)
|
||||
})
|
||||
|
||||
it('heals on the load event', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('launch')
|
||||
})
|
||||
|
||||
it('does nothing at scale 1', () => {
|
||||
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
installStandalone()
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('heals on bfcache resume (pageshow persisted)', () => {
|
||||
installStandalone()
|
||||
setScale(1.2)
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }))
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('bfcache')
|
||||
})
|
||||
|
||||
it('heals when the app returns to the foreground', () => {
|
||||
installStandalone()
|
||||
setScale(1.2)
|
||||
setVisible(false)
|
||||
window.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0)
|
||||
|
||||
setVisible(true)
|
||||
window.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('resume')
|
||||
})
|
||||
|
||||
it('retries up to the attempt budget while the scale stays stuck, then stops', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
|
||||
for (let i = 0; i < CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS; i += 1) {
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
}
|
||||
expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops retrying once the scale heals', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
|
||||
setScale(1)
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
expect(debug?.state.lastSkippedReason).toBe('scale-ok')
|
||||
})
|
||||
|
||||
it('gives each episode a fresh retry budget', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
for (let i = 0; i < CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS; i += 1) {
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
}
|
||||
expect(debug?.state.healedCount).toBe(1 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS)
|
||||
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
expect(debug?.state.healedCount).toBe(2 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS)
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS)
|
||||
expect(debug?.state.healedCount).toBe(3 + CAMPAIGN_IOS_VIEWPORT_HEAL_MAX_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('skips the heal while an editable is focused', () => {
|
||||
installStandalone()
|
||||
focusInput()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(0)
|
||||
expect(debug?.state.lastSkippedReason).toBe('editable-focused')
|
||||
})
|
||||
|
||||
it('skips the heal right after a pinch gesture', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('gesturestart'))
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(0)
|
||||
expect(debug?.state.lastSkippedReason).toBe('recent-pinch')
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the pinch guard fresh for the whole gesture', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('gesturestart'))
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_PINCH_WINDOW_MS + 100)
|
||||
window.dispatchEvent(new Event('gesturechange'))
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(0)
|
||||
expect(debug?.state.lastSkippedReason).toBe('recent-pinch')
|
||||
})
|
||||
|
||||
it('heals after the keyboard closes following an input interaction', () => {
|
||||
installStandalone()
|
||||
const input = focusInput()
|
||||
setScale(1.3)
|
||||
input.blur()
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0)
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS)
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
expect(debug?.state.healedCount).toBe(1)
|
||||
expect(debug?.state.lastHealReason).toBe('keyboard')
|
||||
})
|
||||
|
||||
it('does not schedule a keyboard heal when the focus never touched an editable', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
document.body.dispatchEvent(new Event('focusout'))
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS)
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.healedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('heals on visualViewport resize while nothing is focused', () => {
|
||||
installStandalone()
|
||||
const resizeListener = viewportStub.addEventListener.mock.calls.find(
|
||||
([name]) => name === 'resize',
|
||||
)?.[1]
|
||||
expect(resizeListener).toBeDefined()
|
||||
|
||||
setScale(1.25)
|
||||
resizeListener()
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('resize')
|
||||
})
|
||||
|
||||
it('exposes a manual heal for on-device verification', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
const healed = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.healNow()
|
||||
expect(healed).toBe(true)
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastHealReason).toBe('manual')
|
||||
|
||||
setScale(1)
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.healNow()).toBe(false)
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]?.state.lastSkippedReason).toBe(
|
||||
'scale-ok',
|
||||
)
|
||||
})
|
||||
|
||||
it('teardown cancels pending retries and removes the debug global', () => {
|
||||
installStandalone()
|
||||
setScale(1.3)
|
||||
window.dispatchEvent(new Event('pageshow'))
|
||||
teardown?.()
|
||||
teardown = null
|
||||
expect(window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]).toBeUndefined()
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_RETRY_MS * 3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('teardown cancels a pending keyboard heal timer', () => {
|
||||
installStandalone()
|
||||
const input = focusInput()
|
||||
input.blur()
|
||||
const debug = window[CAMPAIGN_IOS_VIEWPORT_HEAL_DEBUG_GLOBAL]
|
||||
teardown?.()
|
||||
teardown = null
|
||||
|
||||
vi.advanceTimersByTime(CAMPAIGN_IOS_VIEWPORT_HEAL_KEYBOARD_CLOSE_DELAY_MS)
|
||||
expect(debug?.state.healedCount).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the maximum-scale fallback only in standalone when opted in', () => {
|
||||
const viewportMeta = appendViewportMeta()
|
||||
installStandalone({ maximumScaleFallback: true })
|
||||
expect(viewportMeta.content).toContain('maximum-scale=1')
|
||||
expect(viewportMeta.content).toContain('user-scalable=no')
|
||||
})
|
||||
|
||||
it('keeps the viewport meta untouched by default (fallback off)', () => {
|
||||
const viewportMeta = appendViewportMeta()
|
||||
installStandalone()
|
||||
expect(viewportMeta.content).toBe('width=device-width, initial-scale=1')
|
||||
})
|
||||
|
||||
it('keeps the viewport meta untouched in non-standalone even when opted in', () => {
|
||||
const viewportMeta = appendViewportMeta()
|
||||
teardown = installCampaignIosViewportHeal(window, { maximumScaleFallback: true })
|
||||
expect(viewportMeta.content).toBe('width=device-width, initial-scale=1')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue