mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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.
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
/**
|
|
* 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]
|
|
}
|
|
}
|