mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(prefs): Phase 2 — frontend preferences sync (#2507)
* feat(prefs): Phase 2 — frontend preferences sync
- api/user-prefs.ts: Vercel edge function (GET/POST/OPTIONS) that validates
Clerk Bearer token via validateBearerToken(), then calls Convex mutations
via ConvexHttpClient; returns 409 on CONFLICT, 400 on BLOB_TOO_LARGE
- src/utils/cloud-prefs-sync.ts: full sync service with:
- install(): patches Storage.prototype.setItem to auto-detect CLOUD_SYNC_KEYS
changes; multi-tab coordination via storage event; tab-close flush via
fetch({keepalive:true}) (NOT sendBeacon — cannot send Authorization headers)
- onSignIn(): fetch cloud prefs, merge (cloud wins if newer syncVersion),
applyCloudBlob with _suppressPatch flag to avoid re-triggering upload;
shows 5s undo toast on first-ever sign-in with cloud data
- uploadNow(): debounced 5s upload; CONFLICT retry with re-fetch + merge
- onSignOut(): clears sync metadata only (preserves prefs in localStorage)
- setSyncVersion/setState use Storage.prototype.setItem.call() directly
to bypass the setItem patch for non-pref state keys
- src/App.ts: move initAuthState() unconditional (out of isProUser() gate,
per Q2 resolution); wire onSignIn/onSignOut via subscribeAuthState with
_prevUserId tracking to detect transitions; call install() at startup
🤖 Generated with Claude Sonnet 4.6 via Claude Code + Compound Engineering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(prefs): address Greptile review findings on cloud-prefs-sync
- P1: send schemaVersion in all POST bodies (postCloudPrefs, onSignOut
flush, flushOnUnload) so Convex stores the correct schema version
- P2a: flush pending debounce upload on sign-out (keepalive fetch)
before clearing credentials, so last pref change isn't lost
- P2b: only transition to 'synced' in conflict recovery if re-fetch
returns non-null; otherwise set 'error' to surface the failure
- P2c: applyCloudBlob now removes local keys absent from cloud blob,
ensuring cloud state is the source of truth (not additive-only)
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
104
api/user-prefs.ts
Normal file
104
api/user-prefs.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* User preferences sync endpoint.
|
||||
*
|
||||
* GET /api/user-prefs?variant=<variant> — returns current cloud prefs for signed-in user
|
||||
* POST /api/user-prefs — saves prefs blob for signed-in user
|
||||
*
|
||||
* Authentication: Clerk Bearer token in Authorization header.
|
||||
* Requires CONVEX_URL + CLERK_JWT_ISSUER_DOMAIN env vars.
|
||||
*/
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
// @ts-expect-error — JS module, no declaration file
|
||||
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
|
||||
// @ts-expect-error — JS module, no declaration file
|
||||
import { jsonResponse } from './_json-response.js';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
import { validateBearerToken } from '../server/auth-session';
|
||||
|
||||
export default async function handler(req: Request): Promise<Response> {
|
||||
if (isDisallowedOrigin(req)) {
|
||||
return jsonResponse({ error: 'Origin not allowed' }, 403);
|
||||
}
|
||||
|
||||
const cors = getCorsHeaders(req, 'GET, POST, OPTIONS');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405, cors);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get('Authorization') ?? '';
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||
if (!token) {
|
||||
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
|
||||
}
|
||||
|
||||
const session = await validateBearerToken(token);
|
||||
if (!session.valid || !session.userId) {
|
||||
return jsonResponse({ error: 'UNAUTHENTICATED' }, 401, cors);
|
||||
}
|
||||
|
||||
const convexUrl = process.env.CONVEX_URL;
|
||||
if (!convexUrl) {
|
||||
return jsonResponse({ error: 'Service unavailable' }, 503, cors);
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(convexUrl);
|
||||
client.setAuth(token);
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const url = new URL(req.url);
|
||||
const variant = url.searchParams.get('variant') ?? 'full';
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prefs = await client.query('userPreferences:getPreferences' as any, { variant });
|
||||
return jsonResponse(prefs ?? null, 200, cors);
|
||||
} catch (err) {
|
||||
console.error('[user-prefs] GET error:', err);
|
||||
return jsonResponse({ error: 'Failed to fetch preferences' }, 500, cors);
|
||||
}
|
||||
}
|
||||
|
||||
// POST — save prefs
|
||||
let body: { variant?: unknown; data?: unknown; expectedSyncVersion?: unknown; schemaVersion?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return jsonResponse({ error: 'Invalid JSON' }, 400, cors);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body.variant !== 'string' ||
|
||||
body.data === undefined ||
|
||||
typeof body.expectedSyncVersion !== 'number'
|
||||
) {
|
||||
return jsonResponse({ error: 'MISSING_FIELDS' }, 400, cors);
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await client.mutation('userPreferences:setPreferences' as any, {
|
||||
variant: body.variant,
|
||||
data: body.data,
|
||||
expectedSyncVersion: body.expectedSyncVersion,
|
||||
schemaVersion: typeof body.schemaVersion === 'number' ? body.schemaVersion : undefined,
|
||||
});
|
||||
return jsonResponse(result, 200, cors);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('CONFLICT')) {
|
||||
return jsonResponse({ error: 'CONFLICT' }, 409, cors);
|
||||
}
|
||||
if (msg.includes('BLOB_TOO_LARGE')) {
|
||||
return jsonResponse({ error: 'BLOB_TOO_LARGE' }, 400, cors);
|
||||
}
|
||||
console.error('[user-prefs] POST error:', err);
|
||||
return jsonResponse({ error: 'Failed to save preferences' }, 500, cors);
|
||||
}
|
||||
}
|
||||
17
src/App.ts
17
src/App.ts
@@ -65,6 +65,7 @@ import { EventHandlerManager } from '@/app/event-handlers';
|
||||
import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location';
|
||||
import { showProBanner } from '@/components/ProBanner';
|
||||
import { initAuthState, subscribeAuthState } from '@/services/auth-state';
|
||||
import { install as installCloudPrefsSync, onSignIn as cloudPrefsSignIn, onSignOut as cloudPrefsSignOut } from '@/utils/cloud-prefs-sync';
|
||||
import {
|
||||
CorrelationEngine,
|
||||
militaryAdapter,
|
||||
@@ -779,12 +780,24 @@ export class App {
|
||||
this.bootstrapHydrationState = getBootstrapHydrationState();
|
||||
|
||||
// Verify OAuth OTT and hydrate auth session BEFORE any UI subscribes to auth state
|
||||
await initAuthState();
|
||||
if (isProUser()) {
|
||||
await initAuthState();
|
||||
initAuthAnalytics();
|
||||
}
|
||||
installCloudPrefsSync(SITE_VARIANT);
|
||||
this.enforceFreeTierLimits();
|
||||
this.unsubFreeTier = subscribeAuthState(() => { this.enforceFreeTierLimits(); });
|
||||
|
||||
let _prevUserId: string | null = null;
|
||||
this.unsubFreeTier = subscribeAuthState((session) => {
|
||||
this.enforceFreeTierLimits();
|
||||
const userId = session.user?.id ?? null;
|
||||
if (userId !== null && userId !== _prevUserId) {
|
||||
void cloudPrefsSignIn(userId, SITE_VARIANT);
|
||||
} else if (userId === null && _prevUserId !== null) {
|
||||
cloudPrefsSignOut();
|
||||
}
|
||||
_prevUserId = userId;
|
||||
});
|
||||
|
||||
|
||||
const geoCoordsPromise: Promise<PreciseCoordinates | null> =
|
||||
|
||||
368
src/utils/cloud-prefs-sync.ts
Normal file
368
src/utils/cloud-prefs-sync.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Cloud preferences sync service.
|
||||
*
|
||||
* Syncs CLOUD_SYNC_KEYS to Convex via /api/user-prefs (Vercel edge).
|
||||
*
|
||||
* Lifecycle hooks:
|
||||
* install(variant) — call once at startup (patches localStorage.setItem, wires events)
|
||||
* onSignIn(userId, variant) — fetch cloud prefs and merge on sign-in
|
||||
* onSignOut() — clear sync metadata on sign-out
|
||||
*
|
||||
* Feature flag: VITE_CLOUD_PREFS_ENABLED=true must be set.
|
||||
* Desktop guard: isDesktopRuntime() always skips sync.
|
||||
*/
|
||||
|
||||
import { CLOUD_SYNC_KEYS, type CloudSyncKey } from './sync-keys';
|
||||
import { isDesktopRuntime } from '@/services/runtime';
|
||||
import { getClerkToken } from '@/services/clerk';
|
||||
|
||||
const ENABLED = import.meta.env.VITE_CLOUD_PREFS_ENABLED === 'true';
|
||||
|
||||
// localStorage state keys — never uploaded to cloud
|
||||
const KEY_SYNC_VERSION = 'wm-cloud-sync-version';
|
||||
const KEY_LAST_SYNC_AT = 'wm-last-sync-at';
|
||||
const KEY_SYNC_STATE = 'wm-cloud-sync-state';
|
||||
const KEY_LAST_SIGNED_IN_AS = 'wm-last-signed-in-as';
|
||||
|
||||
const CURRENT_PREFS_SCHEMA_VERSION = 1;
|
||||
const MIGRATIONS: Record<number, (data: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
// Future: MIGRATIONS[2] = (data) => { ...transform... }
|
||||
};
|
||||
|
||||
type SyncState = 'synced' | 'pending' | 'syncing' | 'conflict' | 'offline' | 'signed-out' | 'error';
|
||||
|
||||
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _currentVariant = 'full';
|
||||
let _installed = false;
|
||||
let _suppressPatch = false; // prevents applyCloudBlob from re-triggering upload
|
||||
let _cachedToken: string | null = null; // synchronous token cache for flush()
|
||||
|
||||
// ── Guards ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return ENABLED && !isDesktopRuntime();
|
||||
}
|
||||
|
||||
// ── State helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getSyncVersion(): number {
|
||||
return parseInt(localStorage.getItem(KEY_SYNC_VERSION) ?? '0', 10) || 0;
|
||||
}
|
||||
|
||||
function setSyncVersion(v: number): void {
|
||||
// Use direct Storage.prototype.setItem to bypass our patch (state key, not a pref key)
|
||||
Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, String(v));
|
||||
}
|
||||
|
||||
function setState(s: SyncState): void {
|
||||
Storage.prototype.setItem.call(localStorage, KEY_SYNC_STATE, s);
|
||||
}
|
||||
|
||||
// ── Blob helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function buildCloudBlob(): Record<string, string> {
|
||||
const blob: Record<string, string> = {};
|
||||
for (const key of CLOUD_SYNC_KEYS) {
|
||||
const val = localStorage.getItem(key);
|
||||
if (val !== null) blob[key] = val;
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
function applyCloudBlob(data: Record<string, unknown>): void {
|
||||
_suppressPatch = true;
|
||||
try {
|
||||
for (const key of CLOUD_SYNC_KEYS) {
|
||||
const val = data[key];
|
||||
if (typeof val === 'string') {
|
||||
localStorage.setItem(key, val);
|
||||
} else if (!(key in data)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_suppressPatch = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigrations(
|
||||
data: Record<string, unknown>,
|
||||
fromVersion: number,
|
||||
): Record<string, unknown> {
|
||||
let result = data;
|
||||
for (let v = fromVersion + 1; v <= CURRENT_PREFS_SCHEMA_VERSION; v++) {
|
||||
result = MIGRATIONS[v]?.(result) ?? result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function showUndoToast(prevBlobJson: string): void {
|
||||
document.querySelector('.wm-sync-restore-toast')?.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'wm-sync-restore-toast update-toast';
|
||||
toast.innerHTML = `
|
||||
<div class="update-toast-body">
|
||||
<div class="update-toast-title">Settings restored</div>
|
||||
<div class="update-toast-detail">Your preferences were loaded from the cloud.</div>
|
||||
</div>
|
||||
<button class="update-toast-action" data-action="undo">Undo</button>
|
||||
<button class="update-toast-dismiss" data-action="dismiss" aria-label="Dismiss">\u00d7</button>
|
||||
`;
|
||||
|
||||
const autoTimer = setTimeout(() => toast.remove(), 5000);
|
||||
|
||||
toast.addEventListener('click', (e) => {
|
||||
const action = (e.target as HTMLElement).closest('[data-action]')?.getAttribute('data-action');
|
||||
if (action === 'undo') {
|
||||
const prev = JSON.parse(prevBlobJson) as Record<string, string>;
|
||||
_suppressPatch = true;
|
||||
try {
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (CLOUD_SYNC_KEYS.includes(k as CloudSyncKey)) localStorage.setItem(k, v);
|
||||
}
|
||||
} finally {
|
||||
_suppressPatch = false;
|
||||
}
|
||||
toast.remove();
|
||||
clearTimeout(autoTimer);
|
||||
} else if (action === 'dismiss') {
|
||||
toast.remove();
|
||||
clearTimeout(autoTimer);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface CloudPrefs {
|
||||
data: Record<string, unknown>;
|
||||
schemaVersion: number;
|
||||
syncVersion: number;
|
||||
}
|
||||
|
||||
async function fetchCloudPrefs(token: string, variant: string): Promise<CloudPrefs | null> {
|
||||
const res = await fetch(`/api/user-prefs?variant=${encodeURIComponent(variant)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 401) return null;
|
||||
if (!res.ok) throw new Error(`fetch prefs: ${res.status}`);
|
||||
return (await res.json()) as CloudPrefs | null;
|
||||
}
|
||||
|
||||
async function postCloudPrefs(
|
||||
token: string,
|
||||
variant: string,
|
||||
data: Record<string, string>,
|
||||
expectedSyncVersion: number,
|
||||
): Promise<{ syncVersion: number } | { conflict: true }> {
|
||||
const res = await fetch('/api/user-prefs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ variant, data, expectedSyncVersion, schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }),
|
||||
});
|
||||
if (res.status === 409) return { conflict: true };
|
||||
if (!res.ok) throw new Error(`post prefs: ${res.status}`);
|
||||
return (await res.json()) as { syncVersion: number };
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function onSignIn(userId: string, variant: string): Promise<void> {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
_currentVariant = variant;
|
||||
setState('syncing');
|
||||
|
||||
const token = await getClerkToken();
|
||||
if (!token) { setState('error'); return; }
|
||||
_cachedToken = token;
|
||||
|
||||
try {
|
||||
const cloud = await fetchCloudPrefs(token, variant);
|
||||
|
||||
if (cloud && cloud.syncVersion > getSyncVersion()) {
|
||||
const isFirstEverSync = getSyncVersion() === 0;
|
||||
const prevBlobJson = isFirstEverSync ? JSON.stringify(buildCloudBlob()) : null;
|
||||
|
||||
const migrated = applyMigrations(cloud.data, cloud.schemaVersion ?? 1);
|
||||
applyCloudBlob(migrated);
|
||||
setSyncVersion(cloud.syncVersion);
|
||||
Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now()));
|
||||
|
||||
if (isFirstEverSync && prevBlobJson && Object.keys(cloud.data).length > 0) {
|
||||
showUndoToast(prevBlobJson);
|
||||
}
|
||||
|
||||
setState('synced');
|
||||
} else {
|
||||
const blob = buildCloudBlob();
|
||||
const result = await postCloudPrefs(token, variant, blob, getSyncVersion());
|
||||
|
||||
if ('conflict' in result) {
|
||||
setState('conflict');
|
||||
const fresh = await fetchCloudPrefs(token, variant);
|
||||
if (fresh) {
|
||||
const migrated = applyMigrations(fresh.data, fresh.schemaVersion ?? 1);
|
||||
applyCloudBlob(migrated);
|
||||
setSyncVersion(fresh.syncVersion);
|
||||
setState('synced');
|
||||
} else {
|
||||
setState('error');
|
||||
}
|
||||
} else {
|
||||
setSyncVersion(result.syncVersion);
|
||||
Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now()));
|
||||
setState('synced');
|
||||
}
|
||||
}
|
||||
|
||||
Storage.prototype.setItem.call(localStorage, KEY_LAST_SIGNED_IN_AS, userId);
|
||||
} catch (err) {
|
||||
console.warn('[cloud-prefs] onSignIn failed:', err);
|
||||
setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export function onSignOut(): void {
|
||||
if (!isEnabled()) return;
|
||||
|
||||
if (_debounceTimer !== null && _cachedToken) {
|
||||
// Flush pending upload synchronously before clearing credentials
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
const blob = buildCloudBlob();
|
||||
fetch('/api/user-prefs', {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_cachedToken}` },
|
||||
body: JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }),
|
||||
}).catch(() => { /* best-effort on sign-out */ });
|
||||
} else if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
_cachedToken = null;
|
||||
|
||||
// Preserve prefs; only clear sync metadata
|
||||
localStorage.removeItem(KEY_SYNC_VERSION);
|
||||
localStorage.removeItem(KEY_LAST_SYNC_AT);
|
||||
setState('signed-out');
|
||||
}
|
||||
|
||||
async function uploadNow(variant: string): Promise<void> {
|
||||
const token = await getClerkToken();
|
||||
if (!token) return;
|
||||
_cachedToken = token;
|
||||
|
||||
setState('syncing');
|
||||
|
||||
try {
|
||||
const result = await postCloudPrefs(token, variant, buildCloudBlob(), getSyncVersion());
|
||||
|
||||
if ('conflict' in result) {
|
||||
setState('conflict');
|
||||
const fresh = await fetchCloudPrefs(token, variant);
|
||||
if (fresh) {
|
||||
const migrated = applyMigrations(fresh.data, fresh.schemaVersion ?? 1);
|
||||
applyCloudBlob(migrated);
|
||||
setSyncVersion(fresh.syncVersion);
|
||||
const retryResult = await postCloudPrefs(token, variant, buildCloudBlob(), fresh.syncVersion);
|
||||
if (!('conflict' in retryResult)) {
|
||||
setSyncVersion(retryResult.syncVersion);
|
||||
Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now()));
|
||||
}
|
||||
setState('synced');
|
||||
} else {
|
||||
setState('error');
|
||||
}
|
||||
} else {
|
||||
setSyncVersion(result.syncVersion);
|
||||
Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now()));
|
||||
setState('synced');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[cloud-prefs] uploadNow failed:', err);
|
||||
setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePrefUpload(variant: string): void {
|
||||
setState('pending');
|
||||
if (_debounceTimer !== null) clearTimeout(_debounceTimer);
|
||||
_debounceTimer = setTimeout(async () => {
|
||||
_debounceTimer = null;
|
||||
await uploadNow(variant);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
export function onPrefChange(variant: string): void {
|
||||
if (!isEnabled()) return;
|
||||
_currentVariant = variant;
|
||||
schedulePrefUpload(variant);
|
||||
}
|
||||
|
||||
// ── install ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function install(variant: string): void {
|
||||
if (!isEnabled() || _installed) return;
|
||||
_installed = true;
|
||||
_currentVariant = variant;
|
||||
|
||||
// Patch localStorage.setItem to detect pref changes in this tab.
|
||||
// Use _suppressPatch to prevent applyCloudBlob from triggering spurious uploads.
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
Storage.prototype.setItem = function setItem(key: string, value: string) {
|
||||
originalSetItem.call(this, key, value);
|
||||
if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) {
|
||||
schedulePrefUpload(_currentVariant);
|
||||
}
|
||||
};
|
||||
|
||||
// Multi-tab: another tab wrote a newer syncVersion — cancel our pending upload
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key === KEY_SYNC_VERSION && e.newValue !== null) {
|
||||
const newV = parseInt(e.newValue, 10);
|
||||
if (newV > getSyncVersion()) {
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
setState('synced');
|
||||
}
|
||||
Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, e.newValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tab close: flush pending debounce via fetch with keepalive
|
||||
// (sendBeacon cannot send Authorization headers)
|
||||
const flushOnUnload = (): void => {
|
||||
if (_debounceTimer === null || !_cachedToken) return;
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
|
||||
const blob = buildCloudBlob();
|
||||
const payload = JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION });
|
||||
fetch('/api/user-prefs', {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${_cachedToken}`,
|
||||
},
|
||||
body: payload,
|
||||
}).catch(() => { /* best-effort on unload */ });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flushOnUnload();
|
||||
});
|
||||
window.addEventListener('pagehide', flushOnUnload);
|
||||
}
|
||||
Reference in New Issue
Block a user