mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-17 05:08:13 +08:00
fix(mobile): drain expo fetch requests before reloading the runtime
Signing out (and the other runtime reloads) crashed the app with SIGSEGV on the expo.modules.fetch.RequestQueue thread. Expo's native fetch keeps a closure that owns the JS promise until the response reaches a terminal state; reloading while a request was pending destroyed that closure against the torn-down runtime. Sign-out always had requests pending: clearing the cookie refetches the session queries and popping to the root loads the guest timeline. Expo also installs its fetch as the global `fetch`, so every request in the app goes through it. `network-activity.ts` wraps the global fetch (from the polyfill entry) and the explicit `expo/fetch` imports, tracks each request until the native response reports `readyForJSFinalization`, and can abort them all. `reloadApp` cancels the queries, aborts the in-flight requests, waits for the network to go idle (three seconds at most) and only then reloads. Every `reloadAppAsync` call site goes through it. Verified with the Maestro iOS auth journey against production: register, sign out and log in pass with no crash report. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>13</string>
|
||||
<string>14</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
||||
@@ -13,13 +13,17 @@ import { getAuthStateRevision, getCookie, getLastAuthStateChangeAt } from "./aut
|
||||
import { getClientId, getSessionId } from "./client-session"
|
||||
import { getUserAgent } from "./native/user-agent"
|
||||
import { destination } from "./navigation/biz/Destination"
|
||||
import { trackFetch } from "./network-activity"
|
||||
import { proxyEnv } from "./proxy-env"
|
||||
|
||||
// Tracked so a runtime reload can wait for the requests to settle first (see `reload-app.ts`).
|
||||
const trackedFetch = trackFetch(fetch)
|
||||
|
||||
export const followClient = new FollowClient({
|
||||
credentials: "omit",
|
||||
timeout: 60_000,
|
||||
baseURL: proxyEnv.API_URL,
|
||||
fetch: async (input, options = {}) => fetch(input.toString(), options as any) as any,
|
||||
fetch: async (input, options = {}) => trackedFetch(input.toString(), options as any) as any,
|
||||
})
|
||||
|
||||
export const followApi = followClient.api
|
||||
@@ -127,7 +131,7 @@ followClient.addResponseInterceptor(async (ctx) => {
|
||||
|
||||
/** Whether the API answers at all. Any HTTP status counts; the endpoint needs no session. */
|
||||
const probeApiReachability = async () => {
|
||||
await fetch(`${proxyEnv.API_URL}/status/configs`, {
|
||||
await trackedFetch(`${proxyEnv.API_URL}/status/configs`, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
return true
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getUserAgent } from "./native/user-agent"
|
||||
import { Navigation } from "./navigation/Navigation"
|
||||
import { getEnvProfile, proxyEnv } from "./proxy-env"
|
||||
import { queryClient } from "./query-client"
|
||||
import { reloadApp } from "./reload-app"
|
||||
import { safeSecureStore } from "./secure-store"
|
||||
|
||||
const storagePrefix = "follow_auth"
|
||||
@@ -253,7 +254,7 @@ export const signOut = async () => {
|
||||
await refreshSessionQueries()
|
||||
const dbPath = getDbPath()
|
||||
await FileSystem.deleteAsync(dbPath, { idempotent: true })
|
||||
await expo.reloadAppAsync("User sign out")
|
||||
await reloadApp("User sign out")
|
||||
}
|
||||
|
||||
export const deleteUser = async ({ TOTPCode }: { TOTPCode?: string }) => {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
abortInFlightRequests,
|
||||
getInFlightRequestCount,
|
||||
installGlobalFetchTracking,
|
||||
trackFetch,
|
||||
waitForNetworkIdle,
|
||||
} from "./network-activity"
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<string>
|
||||
resolve: (value: string) => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
const createDeferred = (): Deferred => {
|
||||
let resolve!: Deferred["resolve"]
|
||||
let reject!: Deferred["reject"]
|
||||
const promise = new Promise<string>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** A fetch stand-in that settles only when told to, and rejects when its signal aborts. */
|
||||
const createFakeFetch = () => {
|
||||
const calls: { signal: AbortSignal; deferred: Deferred }[] = []
|
||||
const fetchImpl = (_input: string, init?: { signal?: AbortSignal | null }) => {
|
||||
const deferred = createDeferred()
|
||||
const signal = init!.signal!
|
||||
if (signal.aborted) {
|
||||
deferred.reject(new Error("aborted"))
|
||||
} else {
|
||||
signal.addEventListener("abort", () => deferred.reject(new Error("aborted")), {
|
||||
once: true,
|
||||
})
|
||||
}
|
||||
calls.push({ signal, deferred })
|
||||
return deferred.promise
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
abortInFlightRequests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("trackFetch", () => {
|
||||
it("counts a request while it is in flight and settles the idle wait afterwards", async () => {
|
||||
const { calls, fetchImpl } = createFakeFetch()
|
||||
const tracked = trackFetch(fetchImpl)
|
||||
|
||||
const request = tracked("https://example.test/a")
|
||||
expect(getInFlightRequestCount()).toBe(1)
|
||||
|
||||
const idle = waitForNetworkIdle(1_000)
|
||||
calls[0]!.deferred.resolve("ok")
|
||||
await expect(request).resolves.toBe("ok")
|
||||
await expect(idle).resolves.toBe(true)
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("forwards an abort from the caller's signal", async () => {
|
||||
const { calls, fetchImpl } = createFakeFetch()
|
||||
const tracked = trackFetch(fetchImpl)
|
||||
const outer = new AbortController()
|
||||
|
||||
const request = tracked("https://example.test/b", { signal: outer.signal })
|
||||
outer.abort()
|
||||
|
||||
expect(calls[0]!.signal.aborted).toBe(true)
|
||||
await expect(request).rejects.toThrow("aborted")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("hands an already aborted signal to the fetch implementation", async () => {
|
||||
const { calls, fetchImpl } = createFakeFetch()
|
||||
const tracked = trackFetch(fetchImpl)
|
||||
const outer = new AbortController()
|
||||
outer.abort()
|
||||
|
||||
const request = tracked("https://example.test/c", { signal: outer.signal })
|
||||
expect(calls[0]!.signal.aborted).toBe(true)
|
||||
await expect(request).rejects.toThrow("aborted")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("trackFetch with a finalizable response", () => {
|
||||
const createFakeResponse = () => {
|
||||
const listeners: (() => void)[] = []
|
||||
return {
|
||||
response: {
|
||||
addListener: (_event: string, listener: () => void) => {
|
||||
listeners.push(listener)
|
||||
return {
|
||||
remove: () => {
|
||||
listeners.splice(listeners.indexOf(listener), 1)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
finalize: () => {
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
listenerCount: () => listeners.length,
|
||||
}
|
||||
}
|
||||
|
||||
it("keeps the request in flight until the response reports it is finalized", async () => {
|
||||
const fake = createFakeResponse()
|
||||
const tracked = trackFetch(async (_input: string) => fake.response)
|
||||
|
||||
const response = await tracked("https://example.test/body")
|
||||
expect(response).toBe(fake.response)
|
||||
expect(getInFlightRequestCount()).toBe(1)
|
||||
expect(fake.listenerCount()).toBe(1)
|
||||
|
||||
const idle = waitForNetworkIdle(1_000)
|
||||
fake.finalize()
|
||||
await expect(idle).resolves.toBe(true)
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
expect(fake.listenerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("installGlobalFetchTracking", () => {
|
||||
it("wraps the global fetch once and keeps tracking through it", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const { calls, fetchImpl } = createFakeFetch()
|
||||
globalThis.fetch = fetchImpl as unknown as typeof fetch
|
||||
try {
|
||||
installGlobalFetchTracking()
|
||||
const wrapped = globalThis.fetch
|
||||
installGlobalFetchTracking()
|
||||
expect(globalThis.fetch).toBe(wrapped)
|
||||
|
||||
const request = globalThis.fetch("https://example.test/global")
|
||||
expect(getInFlightRequestCount()).toBe(1)
|
||||
calls[0]!.deferred.resolve("ok")
|
||||
await expect(request).resolves.toBe("ok")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("abortInFlightRequests", () => {
|
||||
it("aborts every tracked request so the network goes idle", async () => {
|
||||
const { fetchImpl } = createFakeFetch()
|
||||
const tracked = trackFetch(fetchImpl)
|
||||
const first = tracked("https://example.test/1")
|
||||
const second = tracked("https://example.test/2")
|
||||
expect(getInFlightRequestCount()).toBe(2)
|
||||
|
||||
const idle = waitForNetworkIdle(1_000)
|
||||
abortInFlightRequests()
|
||||
|
||||
await expect(first).rejects.toThrow("aborted")
|
||||
await expect(second).rejects.toThrow("aborted")
|
||||
await expect(idle).resolves.toBe(true)
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("waitForNetworkIdle", () => {
|
||||
it("resolves immediately when nothing is in flight", async () => {
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
await expect(waitForNetworkIdle(1_000)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it("gives up after the timeout when a request does not settle in time", async () => {
|
||||
vi.useFakeTimers()
|
||||
const deferred = createDeferred()
|
||||
const tracked = trackFetch(() => deferred.promise)
|
||||
const request = tracked("https://example.test/slow")
|
||||
|
||||
const idle = waitForNetworkIdle(500)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await expect(idle).resolves.toBe(false)
|
||||
|
||||
deferred.resolve("late")
|
||||
await expect(request).resolves.toBe("late")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Tracks the `expo/fetch` requests that are in flight so the app can settle them before it
|
||||
* reloads the JS runtime (see `reload-app.ts`).
|
||||
*
|
||||
* Expo's native fetch parks a closure that owns the JS promise until the response reaches a
|
||||
* terminal state. When the runtime is torn down first, that closure is destroyed against the
|
||||
* dead runtime on the fetch queue and the app crashes with SIGSEGV. Aborting a request makes the
|
||||
* native side settle it right away, while the runtime is still alive.
|
||||
*
|
||||
* Expo installs its fetch as the global `fetch` as well, so `installGlobalFetchTracking` wraps
|
||||
* that one too; the API client and the TTS service wrap their explicit `expo/fetch` import.
|
||||
*/
|
||||
|
||||
type FetchInit = { signal?: AbortSignal | null } | undefined
|
||||
|
||||
/** The shape of an `expo/fetch` response we rely on: an event emitter that says when it is done. */
|
||||
type FinalizableResponse = {
|
||||
addListener?: (eventName: string, listener: () => void) => { remove: () => void }
|
||||
}
|
||||
|
||||
/** Emitted by the native response once the task finished, failed or was cancelled. */
|
||||
const FINALIZATION_EVENT = "readyForJSFinalization"
|
||||
|
||||
const inFlight = new Set<AbortController>()
|
||||
const idleListeners = new Set<() => void>()
|
||||
|
||||
const notifyIdle = () => {
|
||||
if (inFlight.size > 0) {
|
||||
return
|
||||
}
|
||||
const listeners = [...idleListeners]
|
||||
idleListeners.clear()
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
const release = (controller: AbortController) => {
|
||||
if (inFlight.delete(controller)) {
|
||||
notifyIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fetch promise settles once the headers arrive, but the native task, and the closure it
|
||||
* holds, live on until the body finished. Keep the request in flight until the response says it
|
||||
* is done; responses without that signal count as done right away.
|
||||
*/
|
||||
const releaseWhenFinalized = (controller: AbortController, response: unknown) => {
|
||||
const emitter = response as FinalizableResponse | null
|
||||
if (!emitter || typeof emitter.addListener !== "function") {
|
||||
release(controller)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = emitter.addListener(FINALIZATION_EVENT, () => {
|
||||
subscription.remove()
|
||||
release(controller)
|
||||
})
|
||||
} catch {
|
||||
release(controller)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a fetch implementation so every request is tracked and can be aborted from
|
||||
* `abortInFlightRequests`. A signal passed by the caller keeps working.
|
||||
*/
|
||||
export const trackFetch = <TInput, TInit extends FetchInit, TResponse>(
|
||||
fetchImpl: (input: TInput, init?: TInit) => Promise<TResponse>,
|
||||
): ((input: TInput, init?: TInit) => Promise<TResponse>) => {
|
||||
return async (input, init) => {
|
||||
const controller = new AbortController()
|
||||
const outerSignal = init?.signal ?? null
|
||||
const forwardAbort = () => controller.abort(outerSignal?.reason)
|
||||
|
||||
if (outerSignal?.aborted) {
|
||||
forwardAbort()
|
||||
} else {
|
||||
outerSignal?.addEventListener("abort", forwardAbort, { once: true })
|
||||
}
|
||||
|
||||
inFlight.add(controller)
|
||||
let response: TResponse
|
||||
try {
|
||||
response = await fetchImpl(input, { ...init, signal: controller.signal } as TInit)
|
||||
} catch (error) {
|
||||
outerSignal?.removeEventListener("abort", forwardAbort)
|
||||
release(controller)
|
||||
throw error
|
||||
}
|
||||
|
||||
outerSignal?.removeEventListener("abort", forwardAbort)
|
||||
releaseWhenFinalized(controller, response)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
let globalFetchTracked = false
|
||||
|
||||
/** Replace the global `fetch` (Expo's own) with the tracked wrapper. Safe to call more than once. */
|
||||
export const installGlobalFetchTracking = () => {
|
||||
if (globalFetchTracked || typeof globalThis.fetch !== "function") {
|
||||
return
|
||||
}
|
||||
const tracked = trackFetch(globalThis.fetch)
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
value: tracked,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
})
|
||||
globalFetchTracked = true
|
||||
}
|
||||
|
||||
export const getInFlightRequestCount = () => inFlight.size
|
||||
|
||||
export const abortInFlightRequests = (reason?: unknown) => {
|
||||
for (const controller of inFlight) {
|
||||
controller.abort(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once no tracked request is in flight, or after `timeoutMs`. Resolves with whether the
|
||||
* network went idle in time.
|
||||
*/
|
||||
export const waitForNetworkIdle = (timeoutMs: number) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
if (inFlight.size === 0) {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
|
||||
const onIdle = () => {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
idleListeners.delete(onIdle)
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
idleListeners.add(onIdle)
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { env, envProfileMap } from "@follow/shared/env.rn"
|
||||
import { getEnvProfiles__dangerously } from "@follow/shared/env.rn"
|
||||
import { createAtomHooks } from "@follow/utils"
|
||||
import { reloadAppAsync } from "expo"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import type { SyncStorage } from "jotai/vanilla/utils/atomWithStorage"
|
||||
|
||||
import { cookieKey } from "./auth"
|
||||
import { getE2EEnvProfile } from "./e2e-config"
|
||||
import { JotaiPersistSyncStorage } from "./jotai"
|
||||
import { reloadApp } from "./reload-app"
|
||||
import { safeSecureStore } from "./secure-store"
|
||||
|
||||
const getForcedEnvProfile = (): keyof typeof envProfileMap | null => {
|
||||
@@ -67,6 +67,6 @@ export const setEnvProfile = (profile: keyof typeof envProfileMap) => {
|
||||
console.warn("SecureStore access failed during env profile switch:", e)
|
||||
}
|
||||
|
||||
reloadAppAsync()
|
||||
void reloadApp("Switch env profile")
|
||||
}
|
||||
export { getEnvProfile, useEnvProfile }
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { abortInFlightRequests, getInFlightRequestCount, trackFetch } from "./network-activity"
|
||||
import { reloadApp } from "./reload-app"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
reloadAppAsync: vi.fn(async () => {}),
|
||||
cancelQueries: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
vi.mock("expo", () => ({ reloadAppAsync: mocks.reloadAppAsync }))
|
||||
vi.mock("./query-client", () => ({ queryClient: { cancelQueries: mocks.cancelQueries } }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.reloadAppAsync.mockClear()
|
||||
mocks.cancelQueries.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
abortInFlightRequests()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("reloadApp", () => {
|
||||
it("cancels queries, aborts in-flight requests and reloads once they settled", async () => {
|
||||
let aborted = false
|
||||
const tracked = trackFetch(
|
||||
(_input: string, init?: { signal?: AbortSignal | null }) =>
|
||||
new Promise<string>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
aborted = true
|
||||
reject(new Error("aborted"))
|
||||
})
|
||||
}),
|
||||
)
|
||||
const pending = tracked("https://example.test/pending").catch(() => "aborted")
|
||||
|
||||
const reload = reloadApp("test")
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mocks.cancelQueries).toHaveBeenCalledTimes(1)
|
||||
expect(aborted).toBe(true)
|
||||
await expect(pending).resolves.toBe("aborted")
|
||||
expect(mocks.reloadAppAsync).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await reload
|
||||
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("test")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("still reloads after the drain timeout when a request ignores the abort", async () => {
|
||||
let finish!: (value: string) => void
|
||||
const tracked = trackFetch(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finish = resolve
|
||||
}),
|
||||
)
|
||||
const stubborn = tracked("https://example.test/stubborn")
|
||||
|
||||
const reload = reloadApp("stuck")
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(mocks.reloadAppAsync).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await reload
|
||||
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("stuck")
|
||||
|
||||
finish("late")
|
||||
await expect(stubborn).resolves.toBe("late")
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
})
|
||||
|
||||
it("reloads right away when the network is already idle", async () => {
|
||||
expect(getInFlightRequestCount()).toBe(0)
|
||||
const reload = reloadApp("idle")
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await reload
|
||||
expect(mocks.reloadAppAsync).toHaveBeenCalledWith("idle")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { reloadAppAsync } from "expo"
|
||||
|
||||
import { abortInFlightRequests, waitForNetworkIdle } from "./network-activity"
|
||||
import { queryClient } from "./query-client"
|
||||
|
||||
const DRAIN_TIMEOUT_MS = 3_000
|
||||
const SETTLE_DELAY_MS = 150
|
||||
|
||||
/**
|
||||
* Reload the JS runtime once no `expo/fetch` request is in flight.
|
||||
*
|
||||
* Reloading while a request is pending crashes the app: Expo's native fetch keeps a closure that
|
||||
* owns the JS promise until the response arrives, and destroying it after the runtime is gone
|
||||
* segfaults on the fetch queue. Cancelling the queries first keeps react-query from retrying,
|
||||
* aborting the requests makes the native side settle them, and the wait lets that happen.
|
||||
*/
|
||||
export const reloadApp = async (reason: string) => {
|
||||
await queryClient.cancelQueries().catch(() => {})
|
||||
abortInFlightRequests()
|
||||
await waitForNetworkIdle(DRAIN_TIMEOUT_MS)
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))
|
||||
await reloadAppAsync(reason)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fetch as expoFetch } from "expo/fetch"
|
||||
import { Directory, File, Paths } from "expo-file-system"
|
||||
|
||||
import { trackFetch } from "@/src/lib/network-activity"
|
||||
|
||||
import { fetchTtsVoices as fetchTtsVoicesCore, requestTtsBytes } from "./tts-core"
|
||||
|
||||
export { DEFAULT_TTS_VOICE, getEntryTtsText, TTS_SERVICE_URL, type TtsVoice } from "./tts-core"
|
||||
@@ -44,7 +46,7 @@ const createCacheFile = (cacheKey: string): TtsCacheFile => {
|
||||
|
||||
const defaultDependencies: TtsDependencies = {
|
||||
createCacheFile,
|
||||
fetch: expoFetch,
|
||||
fetch: trackFetch(expoFetch),
|
||||
}
|
||||
|
||||
export const fetchTtsVoices = async (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/src/components/ui/grouped/GroupedList"
|
||||
import { Switch } from "@/src/components/ui/switch/Switch"
|
||||
import { getDbPath } from "@/src/database"
|
||||
import { reloadApp } from "@/src/lib/reload-app"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
|
||||
import { exportLocalDatabase, importOpml } from "../utils"
|
||||
@@ -80,7 +81,7 @@ export const DataScreen = () => {
|
||||
onPress: async () => {
|
||||
const dbPath = getDbPath()
|
||||
await FileSystem.deleteAsync(dbPath)
|
||||
await expo.reloadAppAsync("Clear Sqlite Data")
|
||||
await reloadApp("Clear Sqlite Data")
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { installGlobalFetchTracking } from "../lib/network-activity"
|
||||
|
||||
// Expo's winter runtime (run by Metro before the main module) installs `expo/fetch` as the global
|
||||
// `fetch`. Wrap it so a runtime reload can wait for every request to settle (see `reload-app.ts`).
|
||||
installGlobalFetchTracking()
|
||||
@@ -1,2 +1,3 @@
|
||||
// import "core-js/proposals/promise-with-resolvers";
|
||||
import "./promise-with-resolvers"
|
||||
import "./fetch-tracking"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PlatformActivityIndicator } from "../components/ui/loading/PlatformActi
|
||||
import { getDbPath } from "../database"
|
||||
import { BugCuteReIcon } from "../icons/bug_cute_re"
|
||||
import { useDatabaseMigration } from "../initialize/migration"
|
||||
import { reloadApp } from "../lib/reload-app"
|
||||
|
||||
export const MigrationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { success, error } = useDatabaseMigration()
|
||||
@@ -26,7 +27,7 @@ export const MigrationProvider = ({ children }: { children: ReactNode }) => {
|
||||
const dbPath = getDbPath()
|
||||
await deleteAsync(dbPath)
|
||||
// Reload the app
|
||||
await expo.reloadAppAsync("Clear Sqlite Data")
|
||||
await reloadApp("Clear Sqlite Data")
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useNavigation } from "@/src/lib/navigation/hooks"
|
||||
import { NavigationSitemapRegistry } from "@/src/lib/navigation/sitemap/registry"
|
||||
import type { NavigationControllerView } from "@/src/lib/navigation/types"
|
||||
import { setEnvProfile, useEnvProfile } from "@/src/lib/proxy-env"
|
||||
import { reloadApp } from "@/src/lib/reload-app"
|
||||
import { toast } from "@/src/lib/toast"
|
||||
import { showUpgradeRequiredDialog } from "@/src/modules/dialogs/UpgradeRequiredDialog"
|
||||
import {
|
||||
@@ -108,7 +109,7 @@ export const DebugScreen: NavigationControllerView = () => {
|
||||
async onPress() {
|
||||
const dbPath = getDbPath()
|
||||
await FileSystem.deleteAsync(dbPath)
|
||||
await expo.reloadAppAsync("Clear Sqlite Data")
|
||||
await reloadApp("Clear Sqlite Data")
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -121,7 +122,7 @@ export const DebugScreen: NavigationControllerView = () => {
|
||||
items: [
|
||||
{
|
||||
title: "Reload App",
|
||||
onPress: () => expo.reloadAppAsync("Reload App"),
|
||||
onPress: () => reloadApp("Reload App"),
|
||||
},
|
||||
{
|
||||
title: "Loading",
|
||||
|
||||
Reference in New Issue
Block a user