@terreno/rtk 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,42 @@
1
1
  import { useToast } from "@terreno/ui";
2
2
  import Constants from "expo-constants";
3
- import { useCallback, useEffect, useState } from "react";
4
- import { Linking } from "react-native";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { AppState, Linking } from "react-native";
5
5
  import { useLazyGetVersionCheckQuery } from "./emptyApi";
6
6
  import { IsWeb } from "./platform";
7
- export const useUpgradeCheck = () => {
7
+ /**
8
+ * Checks the running app build number against the backend's VersionConfig
9
+ * thresholds and returns the current upgrade status.
10
+ *
11
+ * - `isRequired` — the build is below the required threshold; the caller
12
+ * should block the UI (e.g. with `UpgradeRequiredScreen`).
13
+ * - `isWarning` — the build is below the warning threshold; the caller
14
+ * can render a dismissible `Banner` or similar prompt.
15
+ *
16
+ * By default a single check runs on mount. Pass `pollingIntervalMs` and/or
17
+ * `recheckOnForeground` to keep long-lived sessions up to date.
18
+ *
19
+ * @param options - Optional polling and foreground re-check configuration.
20
+ * @returns Current upgrade status, messages, and an `onUpdate` callback.
21
+ */
22
+ export const useUpgradeCheck = (options) => {
23
+ const { pollingIntervalMs, recheckOnForeground = false } = options ?? {};
8
24
  const [isRequired, setIsRequired] = useState(false);
25
+ const [isWarning, setIsWarning] = useState(false);
9
26
  const [requiredMessage, setRequiredMessage] = useState();
10
- const [updateUrl, setUpdateUrl] = useState();
11
27
  const [warningMessage, setWarningMessage] = useState();
28
+ const [updateUrl, setUpdateUrl] = useState();
12
29
  const toast = useToast();
13
30
  const [triggerVersionCheck, result] = useLazyGetVersionCheckQuery();
14
31
  const buildNumber = Constants.expoConfig?.extra?.buildNumber;
32
+ const appState = useRef(AppState.currentState);
33
+ const runCheck = useCallback(() => {
34
+ if (buildNumber === undefined || buildNumber === null) {
35
+ return;
36
+ }
37
+ const platform = IsWeb ? "web" : "mobile";
38
+ void triggerVersionCheck({ platform, version: buildNumber });
39
+ }, [buildNumber, triggerVersionCheck]);
15
40
  const onUpdate = useCallback(() => {
16
41
  if (IsWeb) {
17
42
  window.location.reload();
@@ -26,11 +51,39 @@ export const useUpgradeCheck = () => {
26
51
  console.warn("useUpgradeCheck: no update URL available for mobile update");
27
52
  }
28
53
  }, [updateUrl]);
54
+ // Initial check on mount
55
+ useEffect(() => {
56
+ runCheck();
57
+ }, [runCheck]);
58
+ // Periodic re-check at the configured interval
59
+ useEffect(() => {
60
+ if (!pollingIntervalMs) {
61
+ return;
62
+ }
63
+ const interval = setInterval(runCheck, pollingIntervalMs);
64
+ return () => clearInterval(interval);
65
+ }, [runCheck, pollingIntervalMs]);
66
+ // Re-check when app/tab returns to foreground
67
+ useEffect(() => {
68
+ if (!recheckOnForeground) {
69
+ return;
70
+ }
71
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
72
+ const wasBackground = /inactive|background/.test(appState.current);
73
+ const isNowActive = nextAppState === "active";
74
+ if (wasBackground && isNowActive) {
75
+ runCheck();
76
+ }
77
+ appState.current = nextAppState;
78
+ });
79
+ return () => subscription.remove();
80
+ }, [runCheck, recheckOnForeground]);
29
81
  // Show warning toast in a separate effect. ToastProvider initializes its ref
30
82
  // in useEffect, so we may need to retry when toast becomes available.
31
83
  useEffect(() => {
32
- if (!warningMessage)
84
+ if (!warningMessage) {
33
85
  return;
86
+ }
34
87
  const toastId = toast.warn(warningMessage, { persistent: true });
35
88
  if (toastId) {
36
89
  setWarningMessage(undefined);
@@ -39,14 +92,7 @@ export const useUpgradeCheck = () => {
39
92
  console.warn("useUpgradeCheck: toast not yet available, will retry on next render");
40
93
  }
41
94
  }, [warningMessage, toast]);
42
- useEffect(() => {
43
- if (buildNumber === undefined || buildNumber === null) {
44
- return;
45
- }
46
- const platform = IsWeb ? "web" : "mobile";
47
- void triggerVersionCheck({ platform, version: buildNumber });
48
- }, [buildNumber, triggerVersionCheck]);
49
- // Process the version-check response: block on required, warn on warning
95
+ // Process version-check response — update warning/required state
50
96
  useEffect(() => {
51
97
  if (result.isError) {
52
98
  console.debug("Version check failed, continuing normally", result.error);
@@ -59,15 +105,21 @@ export const useUpgradeCheck = () => {
59
105
  if (status === "required") {
60
106
  setIsRequired(true);
61
107
  setRequiredMessage(message);
108
+ setIsWarning(false);
62
109
  }
63
- else if (status === "warning" && message) {
110
+ else if (status === "warning") {
111
+ setIsWarning(true);
64
112
  setWarningMessage(message);
65
113
  }
114
+ else {
115
+ setIsWarning(false);
116
+ setIsRequired(false);
117
+ }
66
118
  if (responseUpdateUrl) {
67
119
  setUpdateUrl(responseUpdateUrl);
68
120
  }
69
121
  }, [result.data, result.error, result.isError, result.isSuccess]);
70
122
  const canUpdate = IsWeb || !!updateUrl;
71
- return { canUpdate, isRequired, onUpdate, requiredMessage };
123
+ return { canUpdate, isRequired, isWarning, onUpdate, requiredMessage, warningMessage };
72
124
  };
73
125
  //# sourceMappingURL=useUpgradeCheck.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useUpgradeCheck.js","sourceRoot":"","sources":["../src/useUpgradeCheck.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useUpgradeCheck.js","sourceRoot":"","sources":["../src/useUpgradeCheck.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "@better-auth/expo": "^1.2.8",
4
4
  "@react-native-async-storage/async-storage": "2.2.0",
5
5
  "@reduxjs/toolkit": "^2.11.1",
6
- "@terreno/ui": "0.9.0",
6
+ "@terreno/ui": "0.9.2",
7
7
  "async-mutex": "^0.5.0",
8
8
  "axios": "^1.13.2",
9
9
  "axios-retry": "^4.5.0",
@@ -57,8 +57,9 @@
57
57
  "lint": "biome check .",
58
58
  "lint:fix": "biome check --write .",
59
59
  "lint:unsafefix": "biome check --write --unsafe .",
60
- "test:ci": "echo 'No tests'"
60
+ "test": "bun test",
61
+ "test:ci": "bun test"
61
62
  },
62
63
  "types": "dist/index.d.ts",
63
- "version": "0.9.0"
64
+ "version": "0.9.2"
64
65
  }
@@ -0,0 +1,306 @@
1
+ import {beforeEach, describe, expect, it} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
4
+
5
+ import {
6
+ type EmailLoginRequest,
7
+ generateAuthSlice,
8
+ selectCurrentUserId,
9
+ selectIsAuthenticating,
10
+ } from "./authSlice";
11
+
12
+ // Create a real RTK Query API with the endpoints that generateAuthSlice expects
13
+ const api = createApi({
14
+ baseQuery: fetchBaseQuery({baseUrl: "/"}),
15
+ endpoints: (builder) => ({
16
+ emailLogin: builder.mutation({
17
+ query: (body: EmailLoginRequest) => ({body, method: "POST", url: "auth/login"}),
18
+ }),
19
+ emailSignUp: builder.mutation({
20
+ query: (body: {email: string; password: string}) => ({
21
+ body,
22
+ method: "POST",
23
+ url: "auth/signup",
24
+ }),
25
+ }),
26
+ googleLogin: builder.mutation({
27
+ query: (body: {idToken: string}) => ({body, method: "POST", url: "auth/google"}),
28
+ }),
29
+ }),
30
+ reducerPath: "terreno-rtk",
31
+ });
32
+
33
+ const createTestStore = () => {
34
+ const {authReducer, middleware, ...rest} = generateAuthSlice(
35
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock
36
+ api as any
37
+ );
38
+
39
+ return {
40
+ ...rest,
41
+ store: configureStore({
42
+ middleware: (getDefault) =>
43
+ getDefault({serializableCheck: false}).concat(api.middleware, ...middleware),
44
+ reducer: {
45
+ [api.reducerPath]: api.reducer,
46
+ auth: authReducer,
47
+ },
48
+ }),
49
+ };
50
+ };
51
+
52
+ describe("generateAuthSlice", () => {
53
+ let store: ReturnType<typeof createTestStore>["store"];
54
+ let authSlice: ReturnType<typeof createTestStore>["authSlice"];
55
+
56
+ beforeEach(() => {
57
+ const result = createTestStore();
58
+ store = result.store;
59
+ authSlice = result.authSlice;
60
+ });
61
+
62
+ describe("initial state", () => {
63
+ it("has correct initial values", () => {
64
+ const state = store.getState().auth;
65
+ expect(state.userId).toBeNull();
66
+ expect(state.error).toBeNull();
67
+ expect(state.isAuthenticating).toBe(false);
68
+ expect(state.lastTokenRefreshTimestamp).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("reducers", () => {
73
+ it("setUserId sets userId and clears isAuthenticating", () => {
74
+ store.dispatch(authSlice.actions.setUserId({userId: "user-123"}));
75
+ const state = store.getState().auth;
76
+ expect(state.userId).toBe("user-123");
77
+ expect(state.isAuthenticating).toBe(false);
78
+ });
79
+
80
+ it("logout clears state", () => {
81
+ store.dispatch(authSlice.actions.setUserId({userId: "user-123"}));
82
+ store.dispatch(authSlice.actions.logout());
83
+ const state = store.getState().auth;
84
+ expect(state.userId).toBeNull();
85
+ expect(state.isAuthenticating).toBe(false);
86
+ expect(state.lastTokenRefreshTimestamp).toBeNull();
87
+ });
88
+
89
+ it("tokenRefreshedSuccess sets timestamp", () => {
90
+ const before = Date.now();
91
+ store.dispatch(authSlice.actions.tokenRefreshedSuccess());
92
+ const state = store.getState().auth;
93
+ expect(state.lastTokenRefreshTimestamp).toBeGreaterThanOrEqual(before);
94
+ expect(state.lastTokenRefreshTimestamp).toBeLessThanOrEqual(Date.now());
95
+ });
96
+ });
97
+
98
+ describe("emailLogin matchers", () => {
99
+ it("matchPending sets isAuthenticating and clears error", () => {
100
+ // Simulate a pending email login action
101
+ store.dispatch({
102
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
103
+ payload: undefined,
104
+ type: "terreno-rtk/executeMutation/pending",
105
+ });
106
+ const state = store.getState().auth;
107
+ expect(state.isAuthenticating).toBe(true);
108
+ expect(state.error).toBeNull();
109
+ });
110
+
111
+ it("matchFulfilled clears isAuthenticating", () => {
112
+ // Set authenticating first
113
+ store.dispatch({
114
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
115
+ payload: undefined,
116
+ type: "terreno-rtk/executeMutation/pending",
117
+ });
118
+ expect(store.getState().auth.isAuthenticating).toBe(true);
119
+
120
+ // Then fulfill
121
+ store.dispatch({
122
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
123
+ payload: {token: "abc", userId: "user-1"},
124
+ type: "terreno-rtk/executeMutation/fulfilled",
125
+ });
126
+ expect(store.getState().auth.isAuthenticating).toBe(false);
127
+ });
128
+
129
+ it("matchRejected sets error and clears isAuthenticating", () => {
130
+ // Set authenticating first
131
+ store.dispatch({
132
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
133
+ payload: undefined,
134
+ type: "terreno-rtk/executeMutation/pending",
135
+ });
136
+
137
+ // Then reject with error
138
+ store.dispatch({
139
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
140
+ payload: {data: {message: "Invalid credentials"}},
141
+ type: "terreno-rtk/executeMutation/rejected",
142
+ });
143
+ const state = store.getState().auth;
144
+ expect(state.isAuthenticating).toBe(false);
145
+ expect(state.error).toBe("Invalid credentials");
146
+ });
147
+ });
148
+
149
+ describe("emailSignUp matchers", () => {
150
+ it("matchPending sets isAuthenticating and clears error", () => {
151
+ store.dispatch({
152
+ meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
153
+ payload: undefined,
154
+ type: "terreno-rtk/executeMutation/pending",
155
+ });
156
+ const state = store.getState().auth;
157
+ expect(state.isAuthenticating).toBe(true);
158
+ expect(state.error).toBeNull();
159
+ });
160
+
161
+ it("matchFulfilled clears isAuthenticating", () => {
162
+ store.dispatch({
163
+ meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
164
+ payload: undefined,
165
+ type: "terreno-rtk/executeMutation/pending",
166
+ });
167
+ store.dispatch({
168
+ meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
169
+ payload: {token: "abc", userId: "user-1"},
170
+ type: "terreno-rtk/executeMutation/fulfilled",
171
+ });
172
+ expect(store.getState().auth.isAuthenticating).toBe(false);
173
+ });
174
+
175
+ it("matchRejected sets error and clears isAuthenticating", () => {
176
+ store.dispatch({
177
+ meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
178
+ payload: undefined,
179
+ type: "terreno-rtk/executeMutation/pending",
180
+ });
181
+ store.dispatch({
182
+ meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
183
+ payload: {data: {message: "Email already exists"}},
184
+ type: "terreno-rtk/executeMutation/rejected",
185
+ });
186
+ const state = store.getState().auth;
187
+ expect(state.isAuthenticating).toBe(false);
188
+ expect(state.error).toBe("Email already exists");
189
+ });
190
+ });
191
+
192
+ describe("googleLogin matchers", () => {
193
+ it("matchPending sets isAuthenticating and clears error", () => {
194
+ store.dispatch({
195
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
196
+ payload: undefined,
197
+ type: "terreno-rtk/executeMutation/pending",
198
+ });
199
+ const state = store.getState().auth;
200
+ expect(state.isAuthenticating).toBe(true);
201
+ expect(state.error).toBeNull();
202
+ });
203
+
204
+ it("matchPending clears stale error from previous attempt", () => {
205
+ // First: fail a login to set an error
206
+ store.dispatch({
207
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
208
+ payload: {data: {message: "Previous error"}},
209
+ type: "terreno-rtk/executeMutation/rejected",
210
+ });
211
+ expect(store.getState().auth.error).toBe("Previous error");
212
+
213
+ // Then: start a google login — should clear the error
214
+ store.dispatch({
215
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-2"},
216
+ payload: undefined,
217
+ type: "terreno-rtk/executeMutation/pending",
218
+ });
219
+ expect(store.getState().auth.error).toBeNull();
220
+ });
221
+
222
+ it("matchFulfilled clears isAuthenticating", () => {
223
+ store.dispatch({
224
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
225
+ payload: undefined,
226
+ type: "terreno-rtk/executeMutation/pending",
227
+ });
228
+ store.dispatch({
229
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
230
+ payload: {token: "abc", userId: "user-1"},
231
+ type: "terreno-rtk/executeMutation/fulfilled",
232
+ });
233
+ expect(store.getState().auth.isAuthenticating).toBe(false);
234
+ });
235
+
236
+ it("matchRejected sets error and clears isAuthenticating", () => {
237
+ store.dispatch({
238
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
239
+ payload: undefined,
240
+ type: "terreno-rtk/executeMutation/pending",
241
+ });
242
+ store.dispatch({
243
+ meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
244
+ payload: {data: {message: "Google auth failed"}},
245
+ type: "terreno-rtk/executeMutation/rejected",
246
+ });
247
+ const state = store.getState().auth;
248
+ expect(state.isAuthenticating).toBe(false);
249
+ expect(state.error).toBe("Google auth failed");
250
+ });
251
+ });
252
+
253
+ describe("persist/REHYDRATE", () => {
254
+ it("resets isAuthenticating to false", () => {
255
+ // Set authenticating
256
+ store.dispatch({
257
+ meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
258
+ payload: undefined,
259
+ type: "terreno-rtk/executeMutation/pending",
260
+ });
261
+ expect(store.getState().auth.isAuthenticating).toBe(true);
262
+
263
+ // Simulate rehydration
264
+ store.dispatch({type: "persist/REHYDRATE"});
265
+ expect(store.getState().auth.isAuthenticating).toBe(false);
266
+ });
267
+ });
268
+ });
269
+
270
+ describe("selectors", () => {
271
+ it("selectCurrentUserId returns userId", () => {
272
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock state
273
+ const state = {auth: {userId: "user-123"}} as any;
274
+ expect(selectCurrentUserId(state)).toBe("user-123");
275
+ });
276
+
277
+ it("selectCurrentUserId returns undefined when no auth state", () => {
278
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock state
279
+ expect(selectCurrentUserId({} as any)).toBeUndefined();
280
+ });
281
+
282
+ it("selectIsAuthenticating returns isAuthenticating", () => {
283
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock state
284
+ const state = {auth: {isAuthenticating: true}} as any;
285
+ expect(selectIsAuthenticating(state)).toBe(true);
286
+ });
287
+
288
+ it("selectIsAuthenticating defaults to false", () => {
289
+ // biome-ignore lint/suspicious/noExplicitAny: Test mock state
290
+ expect(selectIsAuthenticating({} as any)).toBe(false);
291
+ });
292
+ });
293
+
294
+ describe("EmailLoginRequest type", () => {
295
+ it("accepts email-based login", () => {
296
+ const request: EmailLoginRequest = {email: "test@example.com", password: "pass"};
297
+ expect(request.email).toBe("test@example.com");
298
+ expect(request.password).toBe("pass");
299
+ });
300
+
301
+ it("accepts username-based login", () => {
302
+ const request: EmailLoginRequest = {password: "pass", username: "testuser"};
303
+ expect(request.username).toBe("testuser");
304
+ expect(request.password).toBe("pass");
305
+ });
306
+ });
package/src/authSlice.ts CHANGED
@@ -12,6 +12,7 @@ import {IsWeb} from "./platform";
12
12
  type AuthState = {
13
13
  userId: string | null;
14
14
  error: string | null;
15
+ isAuthenticating: boolean;
15
16
  lastTokenRefreshTimestamp: number | null;
16
17
  };
17
18
 
@@ -23,10 +24,9 @@ export interface UserResponse {
23
24
  };
24
25
  }
25
26
 
26
- export interface EmailLoginRequest {
27
- email: string;
28
- password: string;
29
- }
27
+ export type EmailLoginRequest =
28
+ | {email: string; username?: never; password: string}
29
+ | {username: string; email?: never; password: string};
30
30
 
31
31
  export interface EmailSignupRequest {
32
32
  email: string;
@@ -67,8 +67,8 @@ export function generateProfileEndpoints(
67
67
  emailLogin: builder.mutation<UserResponse, EmailLoginRequest>({
68
68
  extraOptions: {maxRetries: 0},
69
69
  invalidatesTags: [path],
70
- query: ({email, password}) => ({
71
- body: {email, password},
70
+ query: ({email, username, password}) => ({
71
+ body: {email, password, username},
72
72
  method: "POST",
73
73
  url: "auth/login",
74
74
  }),
@@ -105,7 +105,14 @@ export function generateProfileEndpoints(
105
105
  export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
106
106
  const authSlice = createSlice({
107
107
  extraReducers: (builder) => {
108
- builder.addMatcher(api.endpoints.emailLogin.matchFulfilled, () => {
108
+ // Reset isAuthenticating on rehydration in case the app was killed mid-login
109
+ builder.addCase("persist/REHYDRATE", (state) => {
110
+ state.isAuthenticating = false;
111
+ });
112
+ builder.addMatcher(api.endpoints.emailLogin.matchFulfilled, (state) => {
113
+ // Note: isAuthenticating is normally cleared by setUserId after the listener
114
+ // middleware stores the token. This is a safety fallback in case the listener fails.
115
+ state.isAuthenticating = false;
109
116
  console.debug("Login success");
110
117
  });
111
118
  builder.addMatcher(
@@ -113,14 +120,18 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
113
120
  // biome-ignore lint/suspicious/noExplicitAny: Generic
114
121
  (state, action: PayloadAction<{data: any}>) => {
115
122
  state.error = action.payload?.data?.message;
123
+ state.isAuthenticating = false;
116
124
  console.debug("Login rejected", action.payload?.data?.message);
117
125
  }
118
126
  );
119
127
  builder.addMatcher(api.endpoints.emailLogin.matchPending, (state) => {
120
128
  state.error = null;
129
+ state.isAuthenticating = true;
121
130
  console.debug("Login pending");
122
131
  });
123
- builder.addMatcher(api.endpoints.emailSignUp.matchFulfilled, () => {
132
+ builder.addMatcher(api.endpoints.emailSignUp.matchFulfilled, (state) => {
133
+ // Safety fallback: clear isAuthenticating in case the listener middleware fails.
134
+ state.isAuthenticating = false;
124
135
  console.debug("Signup success");
125
136
  });
126
137
  builder.addMatcher(
@@ -128,23 +139,48 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
128
139
  // biome-ignore lint/suspicious/noExplicitAny: Generic
129
140
  (state, action: PayloadAction<{data: any}>) => {
130
141
  state.error = action.payload?.data?.message;
142
+ state.isAuthenticating = false;
131
143
  console.debug("Signup rejected", action.payload);
132
144
  }
133
145
  );
134
146
  builder.addMatcher(api.endpoints.emailSignUp.matchPending, (state) => {
135
147
  state.error = null;
148
+ state.isAuthenticating = true;
136
149
  console.debug("Signup pending");
137
150
  });
151
+ builder.addMatcher(api.endpoints.googleLogin.matchPending, (state) => {
152
+ state.error = null;
153
+ state.isAuthenticating = true;
154
+ });
155
+ builder.addMatcher(api.endpoints.googleLogin.matchFulfilled, (state) => {
156
+ // Safety fallback: clear isAuthenticating in case the listener middleware fails.
157
+ state.isAuthenticating = false;
158
+ });
159
+ builder.addMatcher(
160
+ api.endpoints.googleLogin.matchRejected,
161
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
162
+ (state, action: PayloadAction<{data: any}>) => {
163
+ state.error = action.payload?.data?.message;
164
+ state.isAuthenticating = false;
165
+ }
166
+ );
138
167
  },
139
- initialState: {error: null, lastTokenRefreshTimestamp: null, userId: null} as AuthState,
168
+ initialState: {
169
+ error: null,
170
+ isAuthenticating: false,
171
+ lastTokenRefreshTimestamp: null,
172
+ userId: null,
173
+ } as AuthState,
140
174
  name: "auth",
141
175
  reducers: {
142
176
  logout: (state) => {
143
177
  state.userId = null;
178
+ state.isAuthenticating = false;
144
179
  state.lastTokenRefreshTimestamp = null;
145
180
  },
146
181
  setUserId: (state, {payload: {userId}}: PayloadAction<{userId: string}>) => {
147
182
  state.userId = userId;
183
+ state.isAuthenticating = false;
148
184
  },
149
185
  tokenRefreshedSuccess: (state) => {
150
186
  state.lastTokenRefreshTimestamp = Date.now();
@@ -249,6 +285,8 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
249
285
  export const selectCurrentUserId = (state: RootState): string | undefined => state.auth?.userId;
250
286
  export const selectLastTokenRefreshTimestamp = (state: RootState): number | null =>
251
287
  state.auth?.lastTokenRefreshTimestamp;
288
+ export const selectIsAuthenticating = (state: RootState): boolean =>
289
+ state.auth?.isAuthenticating ?? false;
252
290
 
253
291
  export const useSelectCurrentUserId = (): string | undefined => {
254
292
  return useSelector((state: RootState): string | undefined => {
@@ -256,6 +294,10 @@ export const useSelectCurrentUserId = (): string | undefined => {
256
294
  });
257
295
  };
258
296
 
297
+ export const useSelectIsAuthenticating = (): boolean => {
298
+ return useSelector((state: RootState): boolean => state.auth?.isAuthenticating ?? false);
299
+ };
300
+
259
301
  export async function getAuthToken(): Promise<string | null> {
260
302
  let token: string | null;
261
303
 
@@ -0,0 +1,28 @@
1
+ import {mock} from "bun:test";
2
+
3
+ mock.module("react-native", () => ({
4
+ Platform: {OS: "web"},
5
+ StyleSheet: {create: (s: unknown) => s},
6
+ }));
7
+
8
+ mock.module("expo-secure-store", () => ({
9
+ deleteItemAsync: async () => {},
10
+ getItemAsync: async () => null,
11
+ setItemAsync: async () => {},
12
+ }));
13
+
14
+ mock.module("@react-native-async-storage/async-storage", () => ({
15
+ default: {
16
+ getItem: async () => null,
17
+ removeItem: async () => {},
18
+ setItem: async () => {},
19
+ },
20
+ }));
21
+
22
+ mock.module("expo-constants", () => ({
23
+ default: {expoConfig: {extra: {}}},
24
+ }));
25
+
26
+ mock.module("expo-network", () => ({
27
+ getNetworkStateAsync: async () => ({isConnected: true}),
28
+ }));