From 467760142984b60e08e1057a37c07f4584e8a58b Mon Sep 17 00:00:00 2001 From: Terijaki <590522+terijaki@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:49:49 +0200 Subject: [PATCH] fix(expo): read cached session data from SecureStore on app startup (#8953) Co-authored-by: Taesu Co-authored-by: Taesu <166604494+bytaesu@users.noreply.github.com> --- .../fix-expo-session-cache-hydration.md | 5 + packages/expo/src/client.ts | 20 +++ packages/expo/test/expo.test.ts | 122 ++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 .changeset/fix-expo-session-cache-hydration.md diff --git a/.changeset/fix-expo-session-cache-hydration.md b/.changeset/fix-expo-session-cache-hydration.md new file mode 100644 index 0000000000..eb821c8882 --- /dev/null +++ b/.changeset/fix-expo-session-cache-hydration.md @@ -0,0 +1,5 @@ +--- +"@better-auth/expo": patch +--- + +Read cached session data from SecureStore on app startup to eliminate login screen flash for returning users diff --git a/packages/expo/src/client.ts b/packages/expo/src/client.ts index ace9234891..38820a92be 100644 --- a/packages/expo/src/client.ts +++ b/packages/expo/src/client.ts @@ -3,6 +3,7 @@ import type { ClientFetchOption, ClientStore, } from "@better-auth/core"; +import type { Session, User } from "@better-auth/core/db"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { parseSetCookieHeader, @@ -290,6 +291,25 @@ export const expoClient = (opts: ExpoClientOptions) => { version: PACKAGE_VERSION, getActions(_, $store) { store = $store; + // Restore the last persisted session as the initial value of the session atom + const sessionAtom = $store.atoms.session; + if (!isWeb && !opts?.disableCache && sessionAtom) { + const raw = storage.getItem(localCacheName); + const cached = raw + ? safeJSONParse<{ user: User; session: Session }>(raw) + : null; + const exp = cached?.session?.expiresAt; + const expMs = exp ? new Date(exp).getTime() : Number.NaN; + const isFresh = + !!cached?.user?.id && !!cached.session?.id && expMs > Date.now(); + if (isFresh) { + sessionAtom.set({ + ...sessionAtom.get(), + data: cached, + error: null, + }); + } + } return { /** * Get the stored cookie. diff --git a/packages/expo/test/expo.test.ts b/packages/expo/test/expo.test.ts index 54e6c08406..e59418af3a 100644 --- a/packages/expo/test/expo.test.ts +++ b/packages/expo/test/expo.test.ts @@ -1126,6 +1126,128 @@ describe("expo deep link cookie injection for verify-email", async () => { }); }); +/** + * @see https://github.com/better-auth/better-auth/issues/8952 + */ +describe("expo session cache hydration", async () => { + it("preserves additional fields through the cache round-trip", async () => { + const storage = new Map(); + const sharedClientOptions = { + storage: { + getItem: (key: string) => storage.get(key) || null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + }; + const serverConfig = { + emailAndPassword: { enabled: true }, + user: { + additionalFields: { + favoriteColor: { + type: "string" as const, + defaultValue: "blue", + }, + }, + }, + session: { + additionalFields: { + deviceLabel: { + type: "string" as const, + defaultValue: "test-device", + }, + }, + }, + plugins: [expo()], + trustedOrigins: ["better-auth://"], + }; + + const { client: writer, testUser } = await getTestInstance(serverConfig, { + clientOptions: { plugins: [expoClient(sharedClientOptions)] }, + }); + await writer.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + await writer.getSession(); + + const { client: coldStart } = await getTestInstance(serverConfig, { + clientOptions: { plugins: [expoClient(sharedClientOptions)] }, + }); + const atom = coldStart.$store.atoms.session!.get(); + expect(atom.data).toMatchObject({ + user: { favoriteColor: "blue" }, + session: { deviceLabel: "test-device" }, + }); + // Hydration is optimistic; /get-session will still be awaited. + expect(atom.isPending).toBe(true); + }); + + it.each([ + ["expired", new Date(Date.now() - 60_000).toISOString()], + ["missing", undefined], + ["invalid", "not-a-date"], + ])("does not hydrate when session.expiresAt is %s", async (_label, expiresAt) => { + const storage = new Map(); + storage.set( + "better-auth_session_data", + JSON.stringify({ + user: { id: "u1" }, + session: { id: "s1", expiresAt }, + }), + ); + + const { client } = await getTestInstance( + { plugins: [expo()], trustedOrigins: ["better-auth://"] }, + { + clientOptions: { + plugins: [ + expoClient({ + storage: { + getItem: (k) => storage.get(k) || null, + setItem: (k, v) => storage.set(k, v), + }, + }), + ], + }, + }, + ); + + expect(client.$store.atoms.session!.get().data).toBeNull(); + }); + + it("does not hydrate when disableCache is set", async () => { + const storage = new Map(); + storage.set( + "better-auth_session_data", + JSON.stringify({ + user: { id: "u1" }, + session: { + id: "s1", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + }), + ); + + const { client } = await getTestInstance( + { plugins: [expo()], trustedOrigins: ["better-auth://"] }, + { + clientOptions: { + plugins: [ + expoClient({ + disableCache: true, + storage: { + getItem: (k) => storage.get(k) || null, + setItem: (k, v) => storage.set(k, v), + }, + }), + ], + }, + }, + ); + + expect(client.$store.atoms.session!.get().data).toBeNull(); + }); +}); + describe("ExpoFocusManager duplicate notification prevention", () => { it("should not notify listeners when setFocused is called with the same value", async () => { const { setupExpoFocusManager } = await import("../src/focus-manager");