fix: harden setting sync auth lifecycle (#4872)

* fix: harden setting sync auth lifecycle

* fix: preserve legacy setting sync queue

* fix: keep setting queue on auth flaps

* fix: persist queue reset on auth errors

* fix: initialize setting sync queue earlier

* fix: await mobile sync queue load
This commit is contained in:
DIYgod 2026-02-24 12:24:20 +08:00 committed by GitHub
parent a491a82b58
commit 330ee9cd1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 341 additions and 29 deletions

View File

@ -2,6 +2,8 @@ import { initializeDayjs } from "@follow/components/dayjs"
import { registerGlobalContext } from "@follow/shared/bridge"
import { DEV, ELECTRON_BUILD, IN_ELECTRON } from "@follow/shared/constants"
import { hydrateDatabaseToStore } from "@follow/store/hydrate"
import { whoami } from "@follow/store/user/getters"
import { userSyncService } from "@follow/store/user/store"
import { tracker } from "@follow/tracker"
import { repository } from "@pkg"
import { enableMapSet } from "immer"
@ -82,14 +84,25 @@ export const initializeApp = async () => {
apm("initializeSettings", initializeSettings)
await apm("i18n", initI18n)
apm("setting sync", () => {
settingSyncQueue.init()
settingSyncQueue.syncLocal()
})
await apm("initAnalytics", initAnalytics)
void apm("setting sync", async () => {
await settingSyncQueue.init()
await userSyncService.whoami().catch(() => null)
if (!whoami()) {
return
}
await settingSyncQueue.syncLocal()
}).catch((error) => {
appLog("setting sync failed", error)
void tracker.manager.captureException(error, {
module: "setting_sync",
stage: "bootstrap",
})
})
const loadingTime = Date.now() - now
appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`)

View File

@ -1,8 +1,11 @@
import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface"
import { whoami } from "@follow/store/user/getters"
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { getStorageNS } from "@follow/utils/ns"
import { isEmptyObject, sleep } from "@follow/utils/utils"
import type { SettingsTab } from "@follow-app/client-sdk"
import { FollowAPIError } from "@follow-app/client-sdk"
import { omit } from "es-toolkit/compat"
import type { PrimitiveAtom } from "jotai"
@ -56,14 +59,56 @@ const bizSettingKeyToTabMapping = {
ai: "ai",
}
const isUnauthorizedError = (error: unknown) => {
if (error instanceof FollowAPIError) {
return error.status === 401
}
if (error && typeof error === "object" && "status" in error) {
return Number((error as { status?: unknown }).status) === 401
}
return false
}
export type SettingSyncTab = keyof SettingMapping
export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {
tab: T
payload: Partial<SettingMapping[T]>
date: number
}
interface PersistedSettingSyncQueue {
ownerUserId: string | null
queue: SettingSyncQueueItem[]
}
class SettingSyncQueue {
queue: SettingSyncQueueItem[] = []
private ownerUserId: string | null = null
private getCurrentUserId() {
return whoami()?.id ?? null
}
private bindQueueOwner(currentUserId: string) {
if (this.ownerUserId === null) {
this.ownerUserId = currentUserId
return
}
if (this.ownerUserId !== currentUserId) {
this.ownerUserId = currentUserId
this.queue = []
}
}
private reportSyncError(stage: "flush" | "syncLocal", error: unknown) {
void tracker.manager.captureException(error, {
module: "setting_sync",
stage,
})
}
private disposers: (() => void)[] = []
async init() {
@ -72,6 +117,11 @@ class SettingSyncQueue {
this.load()
const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const tab = bizSettingKeyToTabMapping[data.key]
if (!tab) return
@ -97,14 +147,21 @@ class SettingSyncQueue {
disposer()
}
this.queue = []
this.ownerUserId = null
}
private readonly storageKey = getStorageNS("setting_sync_queue")
private persist() {
if (this.queue.length === 0) {
localStorage.removeItem(this.storageKey)
return
}
localStorage.setItem(this.storageKey, JSON.stringify(this.queue))
const payload: PersistedSettingSyncQueue = {
ownerUserId: this.ownerUserId,
queue: this.queue,
}
localStorage.setItem(this.storageKey, JSON.stringify(payload))
}
private load() {
@ -114,11 +171,37 @@ class SettingSyncQueue {
return
}
const currentUserId = this.getCurrentUserId()
try {
this.queue = JSON.parse(queue)
const parsed = JSON.parse(queue) as unknown
if (Array.isArray(parsed)) {
// Backward compatibility: legacy versions persisted the queue array directly.
this.queue = parsed
this.ownerUserId = currentUserId
} else if (!parsed || typeof parsed !== "object") {
this.queue = []
this.ownerUserId = null
return
} else {
const payload = parsed as Partial<PersistedSettingSyncQueue>
this.queue = Array.isArray(payload.queue) ? payload.queue : []
if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) {
this.ownerUserId = payload.ownerUserId
} else {
// Backward compatibility for payloads without owner information.
this.ownerUserId = currentUserId
}
}
} catch {
/* empty */
}
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
}
private chain = Promise.resolve()
@ -127,6 +210,13 @@ class SettingSyncQueue {
private enqueueTime = Date.now()
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
const now = Date.now()
if (isEmptyObject(payload)) {
return
@ -144,6 +234,13 @@ class SettingSyncQueue {
}
private async flush() {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
if (navigator.onLine === false) {
return
}
@ -191,10 +288,27 @@ class SettingSyncQueue {
promises.push(promise)
}
await Promise.all(promises)
try {
await Promise.all(promises)
} catch (error) {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return
}
this.reportSyncError("flush", error)
}
}
replaceRemote(tab?: SettingSyncTab) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return this.chain
}
this.bindQueueOwner(currentUserId)
if (!tab) {
const promises = [] as Promise<any>[]
for (const tab in localSettingGetterMap) {
@ -225,7 +339,24 @@ class SettingSyncQueue {
}
async syncLocal() {
const remoteSettings = await settings.get().prefetch()
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const remoteSettings = await settings
.get()
.prefetch()
.catch((error) => {
if (isUnauthorizedError(error)) {
this.queue = []
this.ownerUserId = currentUserId
return null
}
this.reportSyncError("syncLocal", error)
return null
})
if (!remoteSettings) return

View File

@ -1,5 +1,7 @@
import { initializeDB } from "@follow/database/db"
import { hydrateDatabaseToStore } from "@follow/store/hydrate"
import { whoami } from "@follow/store/user/getters"
import { userSyncService } from "@follow/store/user/store"
import { tracker } from "@follow/tracker"
import { nativeApplicationVersion } from "expo-application"
@ -14,6 +16,20 @@ import { hydrateQueryClient, hydrateSettings } from "./hydrate"
import { migrateDatabase } from "./migration"
import { initializePlayer } from "./player"
type RequestIdleCallback = (callback: () => void, options?: { timeout?: number }) => number
const runWhenIdle = (callback: () => void) => {
const requestIdle = (globalThis as { requestIdleCallback?: RequestIdleCallback })
.requestIdleCallback
if (requestIdle) {
requestIdle(callback, { timeout: 5000 })
return
}
setTimeout(callback, 0)
}
/* eslint-disable no-console */
export const initializeApp = async () => {
console.log(`Initialize...`)
@ -38,19 +54,27 @@ export const initializeApp = async () => {
dataHydratedTime = Date.now() - dataHydratedTime
await apm("hydrateQueryClient", hydrateQueryClient)
await apm("initializeAppCheck", initializeAppCheck)
requestIdleCallback(
() => {
apm("initializePlayer", initializePlayer)
},
{ timeout: 5000 }, // Max delay of 5 seconds
)
apm("setting sync", () => {
settingSyncQueue.init()
settingSyncQueue.syncLocal()
runWhenIdle(() => {
apm("initializePlayer", initializePlayer)
})
await initAnalytics()
void apm("setting sync", async () => {
await settingSyncQueue.init()
await userSyncService.whoami().catch(() => null)
if (!whoami()) {
return
}
await settingSyncQueue.syncLocal()
}).catch((error) => {
console.error("setting sync failed", error)
void tracker.manager.captureException(error, {
module: "setting_sync",
stage: "bootstrap",
})
})
const loadingTime = Date.now() - now
tracker.appInit({
rn: true,

View File

@ -1,7 +1,10 @@
import type { GeneralSettings, UISettings } from "@follow/shared/settings/interface"
import { whoami } from "@follow/store/user/getters"
import { tracker } from "@follow/tracker"
import { isEmptyObject, jotaiStore, sleep } from "@follow/utils"
import { EventBus } from "@follow/utils/event-bus"
import type { SettingsTab } from "@follow-app/client-sdk"
import { FollowAPIError } from "@follow-app/client-sdk"
import { omit } from "es-toolkit/compat"
import type { PrimitiveAtom } from "jotai"
@ -47,6 +50,18 @@ const bizSettingKeyToTabMapping = {
general: "general",
}
const isUnauthorizedError = (error: unknown) => {
if (error instanceof FollowAPIError) {
return error.status === 401
}
if (error && typeof error === "object" && "status" in error) {
return Number((error as { status?: unknown }).status) === 401
}
return false
}
export type SettingSyncTab = keyof SettingMapping
export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {
tab: T
@ -54,6 +69,11 @@ export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab>
date: number
}
interface PersistedSettingSyncQueue {
ownerUserId: string | null
queue: SettingSyncQueueItem[]
}
declare module "@follow/utils/event-bus" {
interface CustomEvent {
SETTING_CHANGE_EVENT: {
@ -65,14 +85,49 @@ declare module "@follow/utils/event-bus" {
class SettingSyncQueue {
queue: SettingSyncQueueItem[] = []
private ownerUserId: string | null = null
private getCurrentUserId() {
return whoami()?.id ?? null
}
private bindQueueOwner(currentUserId: string) {
if (this.ownerUserId === null) {
this.ownerUserId = currentUserId
return
}
if (this.ownerUserId !== currentUserId) {
this.ownerUserId = currentUserId
this.queue = []
}
}
private reportSyncError(stage: "flush" | "syncLocal", error: unknown) {
void tracker.manager.captureException(error, {
module: "setting_sync",
stage,
})
}
private async clearQueueAndPersist(ownerUserId: string | null) {
this.queue = []
this.ownerUserId = ownerUserId
await kv.delete(this.storageKey)
}
private disposers: (() => void)[] = []
async init() {
this.teardown()
this.load()
const loadPromise = this.load()
const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab
if (!tab) return
@ -80,10 +135,12 @@ class SettingSyncQueue {
if (isEmptyObject(nextPayload)) return
this.enqueue(tab, nextPayload)
this.persist()
void this.persist()
})
this.disposers.push(d1)
await loadPromise
}
teardown() {
@ -91,28 +148,69 @@ class SettingSyncQueue {
disposer()
}
this.queue = []
this.ownerUserId = null
}
private readonly storageKey = "setting_sync_queue"
private async persist() {
if (this.queue.length === 0) {
kv.delete(this.storageKey)
return
}
kv.set(this.storageKey, JSON.stringify(this.queue))
const payload: PersistedSettingSyncQueue = {
ownerUserId: this.ownerUserId,
queue: this.queue,
}
kv.set(this.storageKey, JSON.stringify(payload))
}
private async load() {
const queue = await kv.get(this.storageKey)
kv.delete(this.storageKey)
await kv.delete(this.storageKey)
if (!queue) {
return
}
const currentUserId = this.getCurrentUserId()
let nextQueue: SettingSyncQueueItem[] = []
let nextOwnerUserId: string | null = null
try {
this.queue = JSON.parse(queue)
const parsed = JSON.parse(queue) as unknown
if (Array.isArray(parsed)) {
// Backward compatibility: legacy versions persisted the queue array directly.
nextQueue = parsed
nextOwnerUserId = currentUserId
} else if (!parsed || typeof parsed !== "object") {
return
} else {
const payload = parsed as Partial<PersistedSettingSyncQueue>
nextQueue = Array.isArray(payload.queue) ? payload.queue : []
if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) {
nextOwnerUserId = payload.ownerUserId
} else {
// Backward compatibility for payloads without owner information.
nextOwnerUserId = currentUserId
}
}
} catch {
/* empty */
return
}
// If queue state has already changed after init starts, keep the newer in-memory state.
if (this.queue.length > 0 || this.ownerUserId !== null) {
return
}
this.queue = nextQueue
this.ownerUserId = nextOwnerUserId
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
}
private chain = Promise.resolve()
@ -121,6 +219,13 @@ class SettingSyncQueue {
private enqueueTime = Date.now()
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
const now = Date.now()
if (isEmptyObject(payload)) {
return
@ -138,6 +243,13 @@ class SettingSyncQueue {
}
private async flush() {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return
}
this.bindQueueOwner(currentUserId)
if (navigator.onLine === false) {
return
}
@ -189,10 +301,26 @@ class SettingSyncQueue {
promises.push(promise)
}
await Promise.all(promises)
try {
await Promise.all(promises)
} catch (error) {
if (isUnauthorizedError(error)) {
await this.clearQueueAndPersist(currentUserId)
return
}
this.reportSyncError("flush", error)
}
}
replaceRemote(tab?: SettingSyncTab) {
const currentUserId = this.getCurrentUserId()
if (!currentUserId) {
return this.chain
}
this.bindQueueOwner(currentUserId)
if (!tab) {
const promises = [] as Promise<any>[]
for (const tab in localSettingGetterMap) {
@ -238,7 +366,23 @@ class SettingSyncQueue {
return promise
}
async syncLocal() {
const remoteSettings = await this.fetchSettingRemote()
const currentUserId = this.getCurrentUserId()
if (!currentUserId) return
this.bindQueueOwner(currentUserId)
let remoteSettings: Awaited<ReturnType<typeof this.fetchSettingRemote>> | null = null
try {
remoteSettings = await this.fetchSettingRemote()
} catch (error) {
if (isUnauthorizedError(error)) {
await this.clearQueueAndPersist(currentUserId)
return
}
this.reportSyncError("syncLocal", error)
return
}
if (!remoteSettings) return
if (__DEV__) {