/** * Web-app Socket.IO connection. * * A phone-facing web app opens ONE Socket.IO connection to phyhub, * authenticated by a short-lived `webAppJwt` minted from a QR claim code * (see web-app-session.service.ts). phyhub force-disconnects the socket when * the token expires, so reconnects are ROUTINE (~every token TTL on a stable * session) — the whole design of this class is keeping a guaranteed-fresh * token available at every handshake and replaying twin subscriptions after * each reconnect. * * Refresh layers, ordered by how phones actually fail (spec 5.3.1): * 1. Foreground timer at `deadlineMs - refreshLeadMs` — renews via the * refresh grant and rotates the stored refresh token. Insufficient * alone: mobile browsers throttle timers in backgrounded tabs. * 2. `visibilitychange` → visible — renews immediately if the token went * stale while the phone slept. Browser only. * 3. Handshake-time guarantee — socket.io `auth` is a CALLBACK that renews * first when the remaining lifetime is under the lead, so every * (re)connect attempt handshakes with a fresh token. This closes the * race in the cloud pattern where the fire-and-forget * `reconnect_attempt` refresh can lose to the handshake. * 4. Terminal fallback — `connect_error` → renew → reconnect. A terminal * renewal failure (401/404) means the refresh grant is dead and the * user must rescan the QR: `sessionTerminated` listeners fire and the * connection tears itself down. 429 → exponential backoff with jitter * (capped at 60s); other failures retry in 30s. * * Persistence (TECH-1468): the rotating refresh grant is written to * localStorage on every rotation (write-before-use), so the session survives * page reloads, tab discards, and browser kills up to the server's absolute * deadline. Boot resumes from the stored grant when no fresh claim code is * present; terminal failures clear the record. See the storage helpers in * web-app-session.service.ts and docs/web-app-session-resilience-design.md. */ import { io, type Socket, type ManagerOptions, type SocketOptions } from 'socket.io-client'; import { clearStoredWebSession, clearWebSessionCodeFromLocation, exchangeWebAppSession, readStoredWebSession, STORED_WEB_SESSION_VERSION, WebAppSessionError, writeStoredWebSession, type StoredWebSession, type WebAppSession, type WebAppSessionFetch, type WebSessionStorageLike, } from './web-app-session.service'; /** * The session's Web twin as delivered on `webAppAuthenticated`. Structural * (not phyhub's full WebTwinResponse): the client only relies on the id and * resolved settings; extra fields pass through untouched. */ export interface WebSessionTwin { id: string; properties?: { desired?: { settings?: Record; }; }; } export interface WebAppConnectResult { socket: Socket; } export interface WebAppConnectionParams { /** Public path segment identifying the WebEndpoint. */ urlId: string; /** * Base URL the session exchange appends `/api/v1/web-sessions` * to. The mint is served by phyhub — through the API gateway's regional * passthrough (`{gateway}/regions/{region}/phyhub`) or phyhub's URL directly. */ sessionBaseUrl: string; phyhubUrl: string; /** * One-time claim code read from the QR URL fragment (see * readWebSessionCodeFromLocation). At least one of `code` / `storedSession` * is required; when both are present the fresh code wins (a new scan is * explicit user intent), with a fallback to the stored grant if the code is * terminally rejected (an expired QR screenshot / history entry). */ code?: string; /** Persisted session record to resume from (see readStoredWebSession). */ storedSession?: StoredWebSession; /** How many ms before the token deadline should we renew? Default 60_000 (60s). */ refreshLeadMs?: number; /** Override fetch implementation (tests). */ fetch?: WebAppSessionFetch; /** Override the socket.io-client factory (tests). */ ioFactory?: (url: string, opts: Partial) => Socket; /** Override the persistence storage (tests). Defaults to localStorage. */ storage?: WebSessionStorageLike | null; /** Optional logger. Defaults to the browser console (verbose lifecycle logging). */ logger?: Pick; } const DEFAULT_REFRESH_LEAD_MS = 60_000; const RETRYABLE_RENEWAL_RETRY_MS = 30_000; const RATE_LIMIT_BACKOFF_BASE_MS = 1_000; const RATE_LIMIT_BACKOFF_CAP_MS = 60_000; // Verbose by default while web apps are young — every lifecycle step logs to // the browser console so early field debugging needs no instrumentation. // Pass a custom `logger` (e.g. a no-op) to silence it. const DEFAULT_LOGGER: Pick = { info: (...args: unknown[]) => console.info(...args), warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), }; export class WebAppConnection { private readonly params: Required< Omit > & { code?: string; storedSession?: StoredWebSession; storage?: WebSessionStorageLike | null; fetch?: WebAppSessionFetch; ioFactory: (url: string, opts: Partial) => Socket; logger: Pick; }; private socket: Socket | null = null; private session: WebAppSession | null = null; private connected = false; private terminated = false; private latestWebTwin: WebSessionTwin | null = null; private terminalError: WebAppSessionError | null = null; private refreshTimer: ReturnType | undefined; private retryTimer: ReturnType | undefined; private rateLimitAttempts = 0; private renewalInFlight: Promise | null = null; private reconnectRecoveryInFlight = false; private subscriptions: Set = new Set(); private sessionTerminatedListeners: Set<(error: WebAppSessionError) => void> = new Set(); // Rejecters for callers awaiting connect() — drained by disconnect() so an // in-flight connect() settles (with an error) instead of hanging forever. private pendingConnectRejecters: Set<(error: WebAppSessionError) => void> = new Set(); private visibilityListener: (() => void) | null = null; private pageshowListener: ((event: PageTransitionEvent) => void) | null = null; constructor(params: WebAppConnectionParams) { if (!params.urlId) throw new Error('WebAppConnection: urlId required'); if (!params.sessionBaseUrl) throw new Error('WebAppConnection: sessionBaseUrl required'); if (!params.phyhubUrl) throw new Error('WebAppConnection: phyhubUrl required'); if (!params.code && !params.storedSession) { throw new Error('WebAppConnection: either code or storedSession required'); } this.params = { urlId: params.urlId, sessionBaseUrl: params.sessionBaseUrl.replace(/\/$/, ''), phyhubUrl: params.phyhubUrl.replace(/\/$/, ''), code: params.code, storedSession: params.storedSession, refreshLeadMs: params.refreshLeadMs ?? DEFAULT_REFRESH_LEAD_MS, fetch: params.fetch, ioFactory: params.ioFactory ?? ((url, opts) => io(url, opts)), storage: params.storage, logger: params.logger ?? DEFAULT_LOGGER, }; } /** * Mint a session from the claim code and open the Socket.IO connection. * Resolves once the server has emitted `webAppAuthenticated`. Rejects when * the claim code is rejected (terminal — the code is one-time-use and * short-lived, so the caller must obtain a fresh QR scan). */ public async connect(): Promise { if (this.terminated) { throw this.terminalError ?? new WebAppSessionError('web-app session terminated', undefined, true); } if (this.socket && this.connected) { return { socket: this.socket }; } if (this.socket) { // A reconnect is already in flight (socket.io auto-retry or the // connect_error recovery path) — wait for it rather than re-minting. await this.waitForAuthenticated(this.socket); return { socket: this.socket }; } this.session = await this.establishInitialSession(); this.params.logger.info(`[web-app-connection] dialing phyhub socket at ${this.params.phyhubUrl}`); const socket = this.params.ioFactory(this.params.phyhubUrl, { // auth as a CALLBACK — socket.io-client v4 invokes it before every // handshake, so each (re)connect attempt gets a guaranteed-fresh token // (refresh layer 3). auth: (handshakeCallback: (data: object) => void) => { this.resolveHandshakeAuth(handshakeCallback); }, reconnection: true, transports: ['websocket', 'polling'], }); this.socket = socket; socket.on('webAppAuthenticated', (payload: { status?: string; twin?: WebSessionTwin }) => { this.handleAuthenticated(payload); }); socket.on('disconnect', (reason: string) => { this.handleDisconnect(reason); }); socket.on('connect_error', (error: Error) => { this.handleConnectError(error); }); this.attachVisibilityListener(); this.attachPageshowListener(); await this.waitForAuthenticated(socket); return { socket }; } /** * Boot precedence (resilience design §6.2): a fresh claim code wins — a new * scan is explicit user intent and replaces any stored session. A code that * is terminally rejected (expired QR screenshot, stale history entry) falls * back to a stored session when one exists. With no code, a stored session * resumes silently — this is what makes reload / tab discard / browser kill * survivable. Every successful exchange is persisted write-before-use. */ private async establishInitialSession(): Promise { const { code, storedSession, urlId, sessionBaseUrl } = this.params; if (code) { this.params.logger.info(`[web-app-connection] minting session for '${urlId}' at ${sessionBaseUrl} (claim code)`); try { const mintedSession = await exchangeWebAppSession({ urlId, sessionBaseUrl, code, fetch: this.params.fetch, }); this.params.logger.info( `[web-app-connection] session minted — token expires in ${mintedSession.expiresIn}s (server expiry ${mintedSession.expiresAt})`, ); // The code is redeemed — scrub it from the address bar / history so a // copied URL can't leak a still-live multi-use code. clearWebSessionCodeFromLocation(); this.params.logger.info('[web-app-connection] claim code scrubbed from the URL fragment'); this.persistSession(mintedSession); return mintedSession; } catch (error) { const isTerminal = error instanceof WebAppSessionError && error.terminal; if (!isTerminal || !storedSession) { throw error; } this.params.logger.warn( '[web-app-connection] claim code terminally rejected — falling back to the stored session', error instanceof Error ? error.message : error, ); } } if (!storedSession) { throw new WebAppSessionError('web-app connection has no claim code or stored session', undefined, true); } this.params.logger.info(`[web-app-connection] resuming session for '${urlId}' from the stored refresh grant`); try { const resumedSession = await exchangeWebAppSession({ urlId, sessionBaseUrl, refreshToken: storedSession.refreshToken, fetch: this.params.fetch, }); this.params.logger.info( `[web-app-connection] session resumed from stored grant — token expires in ${resumedSession.expiresIn}s (server expiry ${resumedSession.expiresAt})`, ); // The expired-code fallback path can still carry the dead code in the // fragment — scrub it here too (no-op when there is none). clearWebSessionCodeFromLocation(); this.persistSession(resumedSession); return resumedSession; } catch (error) { if (error instanceof WebAppSessionError && error.terminal) { // Grant expired/revoked or the endpoint is gone — the record can only // do harm now (replaying it trips server-side reuse detection). clearStoredWebSession(urlId, this.params.storage); } throw error; } } /** * Persist the rotated grant BEFORE it is handed to the socket * (write-before-use, resilience design §6.3). The crash window between the * server rotating and this write is milliseconds; a crash inside it leaves a * stale record that fail-safes into reuse detection + rescan on the next * boot. A failed write clears the record instead of keeping the old one — * a stale record replays a consumed grant, so no record beats a stale one. */ private persistSession(session: WebAppSession): void { const { urlId } = this.params; const written = writeStoredWebSession( { v: STORED_WEB_SESSION_VERSION, refreshToken: session.refreshToken, sessionDeadlineMs: session.sessionDeadlineMs, urlId, savedAt: Date.now(), }, this.params.storage, ); if (!written) { clearStoredWebSession(urlId, this.params.storage); } } /** * Register a twin subscription. Emits `twinSubscribe` immediately when * connected, and re-emits after every reconnect + `webAppAuthenticated` — * `twin-{id}` room membership does not survive the routine token-expiry * disconnects. */ public registerSubscription(twinId: string): void { if (!twinId) throw new Error('registerSubscription: twinId required'); this.subscriptions.add(twinId); this.params.logger.info(`[web-app-connection] subscribing to twin ${twinId}`); if (this.socket && this.connected) { this.socket.emit('twinSubscribe', { twinId }); } } public unregisterSubscription(twinId: string): void { this.subscriptions.delete(twinId); this.params.logger.info(`[web-app-connection] unsubscribing from twin ${twinId}`); if (this.socket && this.connected) { this.socket.emit('twinUnsubscribe', { twinId }); } } /** * Fires when session renewal fails terminally (refresh grant expired, * revoked, or the endpoint was disabled/deleted). The connection has * already torn itself down when the listener runs — the app should show a * "rescan the QR" state. Returns an unsubscribe handle. */ public onSessionTerminated(listener: (error: WebAppSessionError) => void): () => void { this.sessionTerminatedListeners.add(listener); return () => this.sessionTerminatedListeners.delete(listener); } /** Stop all timers/listeners, disconnect, and drop tokens. Safe to call multiple times. */ public disconnect(): void { this.connected = false; // Settle in-flight connect() awaits — their auth ack can never arrive on // a socket we are about to tear down. When terminate() is the caller, the // terminal error is already set and is the one the awaiter should see. // Each rejecter removes itself from the set via its cleanup, hence the copy. const connectRejecters = Array.from(this.pendingConnectRejecters); const disconnectError = this.terminalError ?? new WebAppSessionError('web-app connection disconnected', undefined, true); for (const rejectPendingConnect of connectRejecters) { rejectPendingConnect(disconnectError); } if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = undefined; } this.detachVisibilityListener(); this.detachPageshowListener(); if (this.socket) { this.socket.disconnect(); this.socket = null; } this.session = null; this.subscriptions.clear(); } /** Currently cached session (for tests / introspection). */ public getSession(): WebAppSession | null { return this.session; } public getSocket(): Socket | null { return this.socket; } public isConnected(): boolean { return this.connected; } // --- internals --- private waitForAuthenticated(socket: Socket): Promise { if (this.connected) return Promise.resolve(); return new Promise((resolve, reject) => { const onAuthenticated = (): void => { cleanup(); resolve(); }; const onTerminated = (error: WebAppSessionError): void => { cleanup(); reject(error); }; const cleanup = (): void => { socket.off('webAppAuthenticated', onAuthenticated); this.sessionTerminatedListeners.delete(onTerminated); this.pendingConnectRejecters.delete(onTerminated); }; socket.on('webAppAuthenticated', onAuthenticated); this.sessionTerminatedListeners.add(onTerminated); this.pendingConnectRejecters.add(onTerminated); }); } private handleAuthenticated(payload: { status?: string; twin?: WebSessionTwin }): void { this.connected = true; this.rateLimitAttempts = 0; // phyhub delivers the session's Web twin on the ack (settings + // source-identity anchor). Keep the last non-empty one: a degraded ack // (twin lookup failed server-side) must not wipe a previously good twin. if (payload?.twin && typeof payload.twin.id === 'string') { this.latestWebTwin = payload.twin; } this.params.logger.info( `[web-app-connection] authenticated (status: ${payload?.status ?? 'unknown'}, twin: ${payload?.twin?.id ?? 'none'})`, ); this.scheduleRefresh(); this.replaySubscriptions(); } /** * The session's Web twin as delivered by the latest `webAppAuthenticated` * ack — `desired.settings` is the app's resolved settings, `id` is the only * legitimate `sourceTwinId` for outgoing twin messages. Null until the * first authenticated ack (or when the server-side twin lookup degraded). */ public getWebTwin(): WebSessionTwin | null { return this.latestWebTwin; } private handleDisconnect(reason: string): void { this.connected = false; this.params.logger.info(`[web-app-connection] socket disconnected (reason: ${reason})`); if (this.terminated) return; // phyhub force-disconnects at token exp with an explicit server // disconnect, and socket.io-client does NOT auto-reconnect after // 'io server disconnect' — re-dial manually; the auth callback supplies // a fresh token for the new handshake. if (reason === 'io server disconnect') { this.params.logger.info('[web-app-connection] server closed the socket (routine token expiry), reconnecting'); this.socket?.connect(); } } private replaySubscriptions(): void { if (!this.socket) return; if (this.subscriptions.size > 0) { this.params.logger.info( `[web-app-connection] replaying ${this.subscriptions.size} twin subscription(s) after (re)connect`, ); } for (const twinId of this.subscriptions) { this.socket.emit('twinSubscribe', { twinId }); } } private resolveHandshakeAuth(handshakeCallback: (data: object) => void): void { void (async () => { let token = this.session?.token ?? ''; try { const freshSession = await this.ensureFreshSession(); token = freshSession.token; } catch (error) { // Hand the (possibly stale) token to the handshake anyway: the server // rejects it with connect_error, which routes into the terminal // fallback path — never leave socket.io hanging without auth data. this.params.logger.warn( 'Failed to renew web app session before handshake', error instanceof Error ? error.message : error, ); } handshakeCallback({ webAppJwt: token }); })(); } /** * Effective renewal lead: the configured lead clamped to half the token's * lifetime. Without the clamp, a `sessionTtlSeconds` at or below the lead * (phyhub's floor is 60s — equal to the default lead) computes a ~0 refresh * delay, so every successful renewal immediately schedules the next one: a * tight mint loop per phone, throttled only by the server rate limiter. */ private getEffectiveRefreshLeadMs(): number { const tokenLifetimeMs = (this.session?.expiresIn ?? 0) * 1000; if (tokenLifetimeMs <= 0) return this.params.refreshLeadMs; return Math.min(this.params.refreshLeadMs, tokenLifetimeMs / 2); } /** Return the cached session if it still has more than the renewal lead left, else renew. */ private async ensureFreshSession(): Promise { if (!this.session) { throw new WebAppSessionError('web-app session not established'); } if (this.session.deadlineMs - Date.now() > this.getEffectiveRefreshLeadMs()) { return this.session; } return this.renewSession(); } /** * Renew via the refresh grant and rotate the stored refresh token. * Single-flight: the timer, the visibility hook, the auth callback, and the * connect_error path can all race — they share one in-flight exchange. */ private renewSession(): Promise { if (this.renewalInFlight) return this.renewalInFlight; let refreshToken = this.session?.refreshToken; // Cheap multi-tab guard (resilience design §6.4): every rotation is // persisted, so a stored token differing from ours means another tab of // the same urlId rotated after us — using ours would trip reuse detection // and kill the session for both tabs. Adopt the newer grant instead. const storedRecord = readStoredWebSession(this.params.urlId, this.params.storage); if (storedRecord && refreshToken && storedRecord.refreshToken !== refreshToken) { this.params.logger.info('[web-app-connection] adopting a newer stored grant (rotated by another tab)'); refreshToken = storedRecord.refreshToken; } if (!refreshToken) { return Promise.reject(new WebAppSessionError('web-app session has no refresh token', undefined, true)); } this.renewalInFlight = exchangeWebAppSession({ urlId: this.params.urlId, sessionBaseUrl: this.params.sessionBaseUrl, refreshToken, fetch: this.params.fetch, }) .then((freshSession) => { this.renewalInFlight = null; this.persistSession(freshSession); this.session = freshSession; this.params.logger.info(`[web-app-connection] renewed session token, server expiry ${freshSession.expiresAt}`); return freshSession; }) .catch((error) => { this.renewalInFlight = null; throw error; }); return this.renewalInFlight; } /** Refresh layer 1 — foreground timer at `deadlineMs - refreshLeadMs`. */ private scheduleRefresh(): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } if (!this.session || this.terminated) return; const delay = Math.max(0, this.session.deadlineMs - Date.now() - this.getEffectiveRefreshLeadMs()); this.params.logger.info(`[web-app-connection] next token refresh scheduled in ${Math.round(delay / 1000)}s`); this.refreshTimer = setTimeout(() => { void this.runScheduledRefresh(); }, delay); // Don't keep a Node event loop alive solely to refresh the token — the // consumer's own work decides lifetime (no-op in browsers). if (typeof this.refreshTimer === 'object' && this.refreshTimer && 'unref' in this.refreshTimer) { (this.refreshTimer as { unref?: () => void }).unref?.(); } } private async runScheduledRefresh(): Promise { if (this.terminated || !this.socket) return; try { await this.renewSession(); } catch (error) { this.handleRenewalError(error, () => { void this.runScheduledRefresh(); }); return; } this.scheduleRefresh(); } /** * Refresh layer 4 — a failed handshake means the token was rejected (or the * network dropped). Renew once through the refresh grant, then re-dial. */ private handleConnectError(error: Error): void { if (this.terminated) return; this.params.logger.warn( '[web-app-connection] connect_error, renewing session before retry', error?.message ?? error, ); if (this.reconnectRecoveryInFlight) return; this.reconnectRecoveryInFlight = true; void (async () => { try { await this.renewSession(); this.socket?.connect(); } catch (renewalError) { this.handleRenewalError(renewalError, () => { this.handleConnectError(error); }); } finally { this.reconnectRecoveryInFlight = false; } })(); } /** * Shared renewal-error taxonomy: terminal → sessionTerminated + teardown; * 429 → exponential backoff with jitter (capped); anything else → 30s retry. */ private handleRenewalError(error: unknown, retry: () => void): void { if (this.terminated) return; if (error instanceof WebAppSessionError && error.terminal) { this.params.logger.error( '[web-app-connection] session renewal terminally rejected; a new QR scan is required', error.message, ); this.terminate(error); return; } const status = error instanceof WebAppSessionError ? error.status : undefined; let delay: number; if (status === 429) { this.rateLimitAttempts += 1; const ceiling = Math.min(RATE_LIMIT_BACKOFF_CAP_MS, RATE_LIMIT_BACKOFF_BASE_MS * 2 ** this.rateLimitAttempts); // Half-jitter: guaranteed-growing floor plus randomness so a fleet of // rate-limited phones doesn't retry in lockstep. delay = ceiling / 2 + Math.random() * (ceiling / 2); this.params.logger.warn( `[web-app-connection] session renewal rate-limited, retrying in ${Math.round(delay)}ms`, error instanceof Error ? error.message : error, ); } else { delay = RETRYABLE_RENEWAL_RETRY_MS; this.params.logger.warn( '[web-app-connection] session renewal failed, retrying in 30s', error instanceof Error ? error.message : error, ); } if (this.retryTimer) clearTimeout(this.retryTimer); this.retryTimer = setTimeout(retry, delay); // Same as refreshTimer: don't keep a Node process alive for a pending // retry (no-op in browsers, where timers are plain numbers). if (typeof this.retryTimer === 'object' && this.retryTimer && 'unref' in this.retryTimer) { (this.retryTimer as { unref?: () => void }).unref?.(); } } private terminate(error: WebAppSessionError): void { if (this.terminated) return; this.terminated = true; this.terminalError = error; // The grant is dead server-side — keeping the record would only replay a // consumed/revoked grant on the next visit and trip reuse detection. clearStoredWebSession(this.params.urlId, this.params.storage); this.params.logger.error(`[web-app-connection] session TERMINATED — a new QR scan is required (${error.message})`); // Snapshot before disconnect() — waitForAuthenticated rejection handles // remove themselves from the set while we iterate a copy. const listeners = Array.from(this.sessionTerminatedListeners); this.disconnect(); for (const listener of listeners) { try { listener(error); } catch (listenerError) { this.params.logger.error('Failed to notify sessionTerminated listener', listenerError); } } } /** * Wake-up recovery shared by `visibilitychange` and `pageshow`: renew when * the token went stale while the phone slept, and re-dial a dead socket * EVEN when the token is still fresh — after a short background the OS * usually killed the socket but not the token, and waiting on the * socket.io heartbeat timeout wastes many visible seconds. */ private handleWake(source: 'visibility' | 'pageshow'): void { if (this.terminated || !this.session) return; if (this.session.deadlineMs - Date.now() > this.getEffectiveRefreshLeadMs()) { if (this.socket && !this.connected) { this.params.logger.info(`[web-app-connection] ${source} wake with a dead socket — re-dialing`); this.socket.connect(); } return; } this.params.logger.info(`[web-app-connection] ${source} wake with a stale token — renewing now`); void (async () => { try { await this.renewSession(); this.scheduleRefresh(); if (this.socket && !this.connected) { this.socket.connect(); } } catch (error) { this.handleRenewalError(error, () => { void this.runScheduledRefresh(); }); } })(); } /** * Refresh layer 2 — browsers throttle/suspend timers in backgrounded tabs, * so on a locked phone the foreground timer never fires. Recover on wake-up. */ private attachVisibilityListener(): void { if (typeof document === 'undefined' || this.visibilityListener) return; this.visibilityListener = () => { if (document.visibilityState !== 'visible') return; this.handleWake('visibility'); }; document.addEventListener('visibilitychange', this.visibilityListener); } private detachVisibilityListener(): void { if (typeof document === 'undefined' || !this.visibilityListener) return; document.removeEventListener('visibilitychange', this.visibilityListener); this.visibilityListener = null; } /** * bfcache restores resurrect the page with frozen timers and a dead socket. * `pageshow` with `persisted === true` is the specified hook for that case * and fires where `visibilitychange` ordering is unreliable across mobile * engines; a non-persisted pageshow is a normal load already mid-connect(). */ private attachPageshowListener(): void { if (typeof window === 'undefined' || this.pageshowListener) return; this.pageshowListener = (event: PageTransitionEvent) => { if (!event.persisted) return; this.handleWake('pageshow'); }; window.addEventListener('pageshow', this.pageshowListener as EventListener); } private detachPageshowListener(): void { if (typeof window === 'undefined' || !this.pageshowListener) return; window.removeEventListener('pageshow', this.pageshowListener as EventListener); this.pageshowListener = null; } }