Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fafea7b
fix(sw): increase session TTL to 24h and add requestSessionWithTimeou…
Just-Insane Apr 11, 2026
00e9095
fix(sw): reset heartbeat backoff on foreground sync; warm preloadedSe…
Just-Insane Apr 11, 2026
8672ff9
chore: add changeset for sw-push-session-recovery
Just-Insane Apr 12, 2026
d18e0df
fix(notifications): replace stale visibility flags with live client ping
Just-Insane Apr 12, 2026
15f5707
fix(notifications): use matchAll visibilityState instead of live ping
Just-Insane Apr 12, 2026
3c5d087
feat(types): add experiment config, sessionSync types and useExperime…
Just-Insane Apr 12, 2026
90f8716
fix(sw): require both visibility signals before suppressing push
Just-Insane Apr 13, 2026
5b3a0fb
fix(sw): expire appIsVisible after 45 s; use hasFocus + heartbeat to …
Just-Insane Apr 13, 2026
f79b75e
revert(sw): remove appIsVisible signaling; rely solely on clients.mat…
Just-Insane Apr 13, 2026
b8d56ac
fix(notifications): restore appIsVisible flag and setAppVisible SW ha…
Just-Insane Apr 13, 2026
a0a6140
fix: convert appEvents to multi-subscriber pattern and cancel retry t…
Just-Insane Apr 13, 2026
350a54a
fix: address PR #671 review comments + add controllerchange handler
Just-Insane Apr 13, 2026
a78e989
fix(sw): replace stale visibility flags with live client ping
Just-Insane Apr 13, 2026
9c6baad
fix: remove presence-auto-idle and bookmarks imports that don't exist…
Just-Insane Apr 14, 2026
b80b708
revert(sw): replace live visibility ping with upstream appIsVisible+m…
Just-Insane Apr 14, 2026
808a654
fix(sw): address review feedback for push session recovery
Just-Insane Apr 15, 2026
5f963cd
chore: remove accidentally committed test.txt
Just-Insane Apr 15, 2026
a5f036e
refactor: use mx.getUserId() instead of activeSession param in useApp…
Just-Insane Apr 15, 2026
bb8b35d
fix: kick sliding sync on foreground return
Just-Insane Apr 15, 2026
14c9d4b
fix(sw): improve push notification reliability and encrypted room han…
Just-Insane Apr 15, 2026
40d971b
chore: fix lint and format issues
Just-Insane Apr 15, 2026
ac62f80
fix(config): enable SW session sync phases for reliable mobile notifi…
Just-Insane Apr 15, 2026
4e28db5
fix(sw): reuse preloaded session in handleMinimalPushPayload
Just-Insane Apr 15, 2026
da209be
fix(badge): only clear app badge when foregrounded
Just-Insane Apr 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sw-push-session-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

fix(sw): improve push session recovery by increasing TTL, adding timeout fallback, and resetting heartbeat backoff on foreground sync
5 changes: 5 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"enabled": true
},

"sessionSync": {
"phase1ForegroundResync": true,
"phase2VisibleHeartbeat": true
},

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
221 changes: 211 additions & 10 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,107 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
import { useAtom } from 'jotai';
import { getSlidingSyncManager } from '$client/initMatrix';
import { togglePusher } from '../features/settings/notifications/PushNotifications';
import { appEvents } from '../utils/appEvents';
import { useClientConfig } from './useClientConfig';
import { useClientConfig, useExperimentVariant } from './useClientConfig';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
Comment on lines +7 to 9
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAppVisibility imports useExperimentVariant from ./useClientConfig, but src/app/hooks/useClientConfig.ts does not export it, and ClientConfig also doesn’t declare the sessionSync property used below. This file will not typecheck/compile as-is; either add the missing export/types to useClientConfig.ts or remove/guard the experiment + sessionSync usage here.

Copilot uses AI. Check for mistakes.
import { pushSubscriptionAtom } from '../state/pushSubscription';
import { mobileOrTablet } from '../utils/user-agent';
import { createDebugLogger } from '../utils/debugLogger';
import { pushSessionToSW } from '../../sw-session';

const debugLog = createDebugLogger('AppVisibility');

const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000;
const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000;

export function useAppVisibility(mx: MatrixClient | undefined) {
const clientConfig = useClientConfig();
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const pushSubAtom = useAtom(pushSubscriptionAtom);
const isMobile = mobileOrTablet();

const sessionSyncConfig = clientConfig.sessionSync;
const sessionSyncVariant = useExperimentVariant(
'sessionSyncStrategy',
mx?.getUserId() ?? undefined
);

// Derive phase flags from experiment variant; fall back to direct config when not in experiment.
const inSessionSync = sessionSyncVariant.inExperiment;
const syncVariant = sessionSyncVariant.variant;
const phase1ForegroundResync = inSessionSync
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase1ForegroundResync === true;
const phase2VisibleHeartbeat = inSessionSync
? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase2VisibleHeartbeat === true;
const phase3AdaptiveBackoffJitter = inSessionSync
? syncVariant === 'session-sync-adaptive'
: sessionSyncConfig?.phase3AdaptiveBackoffJitter === true;

const foregroundDebounceMs = Math.max(
0,
sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS
);
const heartbeatIntervalMs = Math.max(
1000,
sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
);
const resumeHeartbeatSuppressMs = Math.max(
0,
sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS
);
const heartbeatMaxBackoffMs = Math.max(
heartbeatIntervalMs,
sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS
);

const lastForegroundPushAtRef = useRef(0);
const suppressHeartbeatUntilRef = useRef(0);
const heartbeatFailuresRef = useRef(0);

const pushSessionNow = useCallback(
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
const baseUrl = mx?.getHomeserverUrl();
const accessToken = mx?.getAccessToken();
const userId = mx?.getUserId();
const canPush =
!!mx &&
typeof baseUrl === 'string' &&
typeof accessToken === 'string' &&
typeof userId === 'string' &&
'serviceWorker' in navigator &&
!!navigator.serviceWorker.controller;

if (!canPush) {
debugLog.warn('network', 'Skipped SW session sync', {
reason,
hasClient: !!mx,
hasBaseUrl: !!baseUrl,
hasAccessToken: !!accessToken,
hasUserId: !!userId,
hasSwController: !!navigator.serviceWorker?.controller,
});
return 'skipped';
}

pushSessionToSW(baseUrl, accessToken, userId);
debugLog.info('network', 'Pushed session to SW', {
reason,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
});
return 'sent';
},
[mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter]
);

useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = document.visibilityState === 'visible';
Expand All @@ -26,30 +110,147 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
`App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
{ visibilityState: document.visibilityState }
);
appEvents.onVisibilityChange?.(isVisible);
appEvents.emitVisibilityChange(isVisible);
if (!isVisible) {
appEvents.onVisibilityHidden?.();
appEvents.emitVisibilityHidden();
return;
}

// Always kick the sync loop on foreground regardless of phase flags —
// the SDK may be sitting in exponential backoff after iOS froze the tab.
mx?.retryImmediately();
// retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the
// SlidingSync instance directly to abort a stale long-poll and start fresh.
if (mx) getSlidingSyncManager(mx)?.slidingSync.resend();

if (!phase1ForegroundResync) return;

const now = Date.now();
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
lastForegroundPushAtRef.current = now;

if (pushSessionNow('foreground') === 'sent') {
// A successful push proves the SW controller is up — reset adaptive backoff
// so the heartbeat returns to its normal interval immediately rather than
// staying on an inflated delay left over from a prior SW absence period.
if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0;
if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) {
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
}
}
};

const handleFocus = () => {
if (document.visibilityState !== 'visible') return;

// Always kick the sync loop on focus for the same reason as above.
mx?.retryImmediately();
if (mx) getSlidingSyncManager(mx)?.slidingSync.resend();

if (!phase1ForegroundResync) return;

const now = Date.now();
if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
lastForegroundPushAtRef.current = now;

if (pushSessionNow('focus') === 'sent') {
if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0;
if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) {
suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
}
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);

return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, []);
}, [
foregroundDebounceMs,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
resumeHeartbeatSuppressMs,
]);

useEffect(() => {
if (!mx) return;
if (!mx) return undefined;

const handleVisibilityForNotifications = (isVisible: boolean) => {
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
};

appEvents.onVisibilityChange = handleVisibilityForNotifications;
// eslint-disable-next-line consistent-return
const unsubscribe = appEvents.onVisibilityChange(handleVisibilityForNotifications);
return unsubscribe;
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);

useEffect(() => {
if (!phase2VisibleHeartbeat) return undefined;

// Reset adaptive backoff/suppression so a config or session change starts fresh.
heartbeatFailuresRef.current = 0;
suppressHeartbeatUntilRef.current = 0;

let timeoutId: number | undefined;

const getDelayMs = (): number => {
let delay = heartbeatIntervalMs;

if (phase3AdaptiveBackoffJitter) {
const failures = heartbeatFailuresRef.current;
const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs);
delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor));

// Add +-20% jitter to avoid synchronized heartbeat spikes across many clients.
const jitter = 0.8 + Math.random() * 0.4;
delay = Math.max(1000, Math.round(delay * jitter));
}

return delay;
};

const tick = () => {
const now = Date.now();

if (document.visibilityState !== 'visible' || !navigator.onLine) {
timeoutId = window.setTimeout(tick, getDelayMs());
return;
}

if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) {
timeoutId = window.setTimeout(tick, getDelayMs());
return;
}

const result = pushSessionNow('heartbeat');
if (phase3AdaptiveBackoffJitter) {
if (result === 'sent') {
heartbeatFailuresRef.current = 0;
} else {
// 'skipped' means prerequisites (SW controller, session) aren't ready.
// Treat as a transient failure so backoff grows until the SW is ready.
heartbeatFailuresRef.current += 1;
}
}

timeoutId = window.setTimeout(tick, getDelayMs());
};

timeoutId = window.setTimeout(tick, getDelayMs());

return () => {
appEvents.onVisibilityChange = null;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
}, [
heartbeatIntervalMs,
heartbeatMaxBackoffMs,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
]);
}
95 changes: 95 additions & 0 deletions src/app/hooks/useClientConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ export type HashRouterConfig = {
basename?: string;
};

export type ExperimentConfig = {
enabled?: boolean;
rolloutPercentage?: number;
variants?: string[];
controlVariant?: string;
};

export type ExperimentSelection = {
key: string;
enabled: boolean;
rolloutPercentage: number;
variant: string;
inExperiment: boolean;
};

export type SessionSyncConfig = {
phase1ForegroundResync?: boolean;
phase2VisibleHeartbeat?: boolean;
phase3AdaptiveBackoffJitter?: boolean;
foregroundDebounceMs?: number;
heartbeatIntervalMs?: number;
resumeHeartbeatSuppressMs?: number;
heartbeatMaxBackoffMs?: number;
};

export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
Expand All @@ -14,6 +39,10 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;

experiments?: Record<string, ExperimentConfig>;

sessionSync?: SessionSyncConfig;

pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
Expand Down Expand Up @@ -55,6 +84,72 @@ export function useClientConfig(): ClientConfig {
return config;
}

const DEFAULT_CONTROL_VARIANT = 'control';

const normalizeRolloutPercentage = (value?: number): number => {
if (typeof value !== 'number' || Number.isNaN(value)) return 100;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
};

const hashToUInt32 = (input: string): number => {
let hash = 0;
for (let index = 0; index < input.length; index += 1) {
hash = (hash * 131 + input.charCodeAt(index)) % 4294967291;
}
return hash;
};

export const selectExperimentVariant = (
key: string,
experiment: ExperimentConfig | undefined,
subjectId: string | undefined
): ExperimentSelection => {
const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT;
const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter(
(variant) => variant !== controlVariant
);
const enabled = experiment?.enabled === true;
const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage);

if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) {
return {
key,
enabled,
rolloutPercentage,
variant: controlVariant,
inExperiment: false,
};
}

const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000;
const rolloutCutoff = Math.floor(rolloutPercentage * 100);
if (rolloutBucket >= rolloutCutoff) {
return {
key,
enabled,
rolloutPercentage,
variant: controlVariant,
inExperiment: false,
};
}

const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length;
return {
key,
enabled,
rolloutPercentage,
variant: variants[variantIndex],
inExperiment: true,
};
};

export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => {
const clientConfig = useClientConfig();
return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId);
};

export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';

Expand Down
Loading
Loading