@carlonicora/nextjs-jsonapi 1.131.0 → 1.131.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.
@@ -3,7 +3,7 @@
3
3
  import { useAtom } from "jotai";
4
4
  import { atomWithStorage } from "jotai/utils";
5
5
  import { usePathname } from "next/navigation";
6
- import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
6
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { useSocketContext } from "../../../contexts/SocketContext";
8
8
  import { Modules, rehydrate } from "../../../core";
9
9
  import { Action, checkPermissions, ModuleWithPermissions } from "../../../permissions";
@@ -66,13 +66,31 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
66
66
  return descriptor?.get?.call(Modules);
67
67
  };
68
68
 
69
- const currentUser = dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null;
69
+ const currentUser = useMemo(
70
+ () => (dehydratedUser ? rehydrate<UserInterface>(Modules.User, dehydratedUser) : null),
71
+ [dehydratedUser],
72
+ );
70
73
 
71
- const company = currentUser?.company ?? null;
74
+ // Balances pushed by company:tokens_updated. Transient: a real user refresh is
75
+ // authoritative and clears this.
76
+ const [tokenOverride, setTokenOverride] = useState<{
77
+ availableMonthlyTokens: number;
78
+ availableExtraTokens: number;
79
+ } | null>(null);
80
+
81
+ const company = useMemo<CompanyInterface | null>(() => {
82
+ const c = currentUser?.company ?? null;
83
+ if (!c || !tokenOverride) return c;
84
+ return Object.assign(Object.create(Object.getPrototypeOf(c)), c, {
85
+ _availableMonthlyTokens: tokenOverride.availableMonthlyTokens,
86
+ _availableExtraTokens: tokenOverride.availableExtraTokens,
87
+ });
88
+ }, [currentUser, tokenOverride]);
72
89
 
73
90
  const setUser = (user?: UserInterface): void => {
74
91
  if (user) setDehydratedUser(user.dehydrate() as any);
75
92
  else setDehydratedUser(null);
93
+ setTokenOverride(null);
76
94
  };
77
95
 
78
96
  const hasRole = (roleId: string): boolean => {
@@ -159,6 +177,7 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
159
177
  }
160
178
 
161
179
  setDehydratedUser(fullUser.dehydrate() as any);
180
+ setTokenOverride(null);
162
181
  }
163
182
  } catch (error) {
164
183
  console.error("Failed to refresh user data:", error);
@@ -185,7 +204,23 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
185
204
  return;
186
205
  }
187
206
 
188
- const handleCompanyUpdate = (data: { companyId: string; type: string }) => {
207
+ // Hot path: fires once per LLM call. Fully described by its payload — must
208
+ // never trigger an HTTP request.
209
+ const handleTokensUpdated = (data: {
210
+ companyId: string;
211
+ availableMonthlyTokens: number;
212
+ availableExtraTokens: number;
213
+ }) => {
214
+ if (data.companyId !== currentUser.company?.id) return;
215
+ setTokenOverride({
216
+ availableMonthlyTokens: data.availableMonthlyTokens,
217
+ availableExtraTokens: data.availableExtraTokens,
218
+ });
219
+ };
220
+
221
+ // Rare (Stripe webhooks only) and genuinely changes features, modules and
222
+ // permissions — a payload cannot economically carry that, so refetch.
223
+ const handleSubscriptionUpdated = (data: { companyId: string; type: string }) => {
189
224
  if (data.companyId === currentUser.company?.id && !isRefreshingRef.current) {
190
225
  isRefreshingRef.current = true;
191
226
  // Skip cookie update to prevent page reload - only update React state
@@ -195,13 +230,12 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
195
230
  }
196
231
  };
197
232
 
198
- // Both events trigger the same refresh behavior
199
- socket.on("company:tokens_updated", handleCompanyUpdate);
200
- socket.on("company:subscription_updated", handleCompanyUpdate);
233
+ socket.on("company:tokens_updated", handleTokensUpdated);
234
+ socket.on("company:subscription_updated", handleSubscriptionUpdated);
201
235
 
202
236
  return () => {
203
- socket.off("company:tokens_updated", handleCompanyUpdate);
204
- socket.off("company:subscription_updated", handleCompanyUpdate);
237
+ socket.off("company:tokens_updated", handleTokensUpdated);
238
+ socket.off("company:subscription_updated", handleSubscriptionUpdated);
205
239
  };
206
240
  }, [socket, isConnected, currentUser?.company?.id]);
207
241
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { render } from "@testing-library/react";
2
+ import { render, act } from "@testing-library/react";
3
3
 
4
4
  vi.mock("cookies-next", () => ({
5
5
  getCookie: vi.fn(() => undefined),
@@ -9,14 +9,54 @@ vi.mock("next/navigation", () => ({
9
9
  usePathname: () => "/",
10
10
  }));
11
11
 
12
+ const socketMock = vi.hoisted(() => {
13
+ const handlers: Record<string, ((data: any) => void)[]> = {};
14
+ return {
15
+ socket: {
16
+ on: (event: string, handler: (data: any) => void) => {
17
+ (handlers[event] ??= []).push(handler);
18
+ },
19
+ off: (event: string, handler: (data: any) => void) => {
20
+ handlers[event] = (handlers[event] ?? []).filter((h) => h !== handler);
21
+ },
22
+ },
23
+ emit: (event: string, data: any) => {
24
+ (handlers[event] ?? []).forEach((h) => h(data));
25
+ },
26
+ reset: () => {
27
+ for (const key of Object.keys(handlers)) delete handlers[key];
28
+ },
29
+ };
30
+ });
31
+
12
32
  vi.mock("../../../../contexts/SocketContext", () => ({
13
- useSocketContext: () => ({ socket: null, isConnected: false }),
33
+ useSocketContext: () => ({ socket: socketMock.socket, isConnected: true }),
14
34
  }));
15
35
 
16
- vi.mock("../../../../core", () => ({
17
- Modules: { User: { name: "users" } },
18
- rehydrate: (_module: unknown, data: any) => data,
19
- }));
36
+ vi.mock("../../../../core", () => {
37
+ // Mirrors the real Company model: public getters over private backing fields.
38
+ // The token overlay writes the backing fields, so a plain object would not
39
+ // exercise it.
40
+ class FakeCompany {
41
+ id = "";
42
+ monthlyTokens = 0;
43
+ _availableMonthlyTokens = 0;
44
+ _availableExtraTokens = 0;
45
+ get availableMonthlyTokens() {
46
+ return this._availableMonthlyTokens ?? 0;
47
+ }
48
+ get availableExtraTokens() {
49
+ return this._availableExtraTokens ?? 0;
50
+ }
51
+ }
52
+ return {
53
+ Modules: { User: { name: "users" } },
54
+ rehydrate: (_module: unknown, data: any) => ({
55
+ ...data,
56
+ company: data.company ? Object.assign(new FakeCompany(), data.company) : null,
57
+ }),
58
+ };
59
+ });
20
60
 
21
61
  vi.mock("../../../../permissions", () => ({
22
62
  Action: { Read: "read", Write: "write", Create: "create", Update: "update", Delete: "delete" },
@@ -139,3 +179,106 @@ describe("CurrentUserProvider", () => {
139
179
  expect(getByTestId("current-user-id").textContent).toBe("new");
140
180
  });
141
181
  });
182
+
183
+ describe("CurrentUserProvider token updates", () => {
184
+ const STORED_USER = {
185
+ id: "user-1",
186
+ roles: [],
187
+ modules: [],
188
+ company: {
189
+ id: "company-1",
190
+ monthlyTokens: 1000,
191
+ _availableMonthlyTokens: 900,
192
+ _availableExtraTokens: 50,
193
+ },
194
+ };
195
+
196
+ function CompanyConsumer() {
197
+ const { company } = useCurrentUserContext();
198
+ return (
199
+ <div data-testid="balances">
200
+ {company ? `${(company as any).availableMonthlyTokens}/${(company as any).availableExtraTokens}` : "null"}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ beforeEach(() => {
206
+ localStorage.clear();
207
+ socketMock.reset();
208
+ vi.clearAllMocks();
209
+ });
210
+
211
+ it("patches balances from company:tokens_updated without refetching the user", async () => {
212
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
213
+
214
+ const { getByTestId } = render(
215
+ <CurrentUserProvider>
216
+ <CompanyConsumer />
217
+ </CurrentUserProvider>,
218
+ );
219
+
220
+ await new Promise((resolve) => setTimeout(resolve, 50));
221
+ expect(getByTestId("balances").textContent).toBe("900/50");
222
+
223
+ await act(async () => {
224
+ socketMock.emit("company:tokens_updated", {
225
+ type: "company:tokens_updated",
226
+ companyId: "company-1",
227
+ availableMonthlyTokens: 750,
228
+ availableExtraTokens: 40,
229
+ });
230
+ });
231
+
232
+ expect(getByTestId("balances").textContent).toBe("750/40");
233
+ expect(UserService.findFullUser).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("ignores company:tokens_updated for a different company", async () => {
237
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
238
+
239
+ const { getByTestId } = render(
240
+ <CurrentUserProvider>
241
+ <CompanyConsumer />
242
+ </CurrentUserProvider>,
243
+ );
244
+
245
+ await new Promise((resolve) => setTimeout(resolve, 50));
246
+
247
+ await act(async () => {
248
+ socketMock.emit("company:tokens_updated", {
249
+ type: "company:tokens_updated",
250
+ companyId: "someone-else",
251
+ availableMonthlyTokens: 1,
252
+ availableExtraTokens: 1,
253
+ });
254
+ });
255
+
256
+ expect(getByTestId("balances").textContent).toBe("900/50");
257
+ });
258
+
259
+ it("still refetches the full user on company:subscription_updated", async () => {
260
+ localStorage.setItem("user", JSON.stringify(STORED_USER));
261
+ vi.mocked(UserService.findFullUser).mockResolvedValue({
262
+ ...STORED_USER,
263
+ dehydrate: () => STORED_USER,
264
+ } as any);
265
+
266
+ render(
267
+ <CurrentUserProvider>
268
+ <CompanyConsumer />
269
+ </CurrentUserProvider>,
270
+ );
271
+
272
+ await new Promise((resolve) => setTimeout(resolve, 50));
273
+
274
+ await act(async () => {
275
+ socketMock.emit("company:subscription_updated", {
276
+ type: "company:subscription_updated",
277
+ companyId: "company-1",
278
+ });
279
+ await new Promise((resolve) => setTimeout(resolve, 50));
280
+ });
281
+
282
+ expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
283
+ });
284
+ });
@@ -48,7 +48,13 @@ function SheetContent({
48
48
  }) {
49
49
  return (
50
50
  <SheetPortal>
51
- <SheetOverlay />
51
+ {/* forceRender: Base UI skips the backdrop entirely for a NESTED dialog
52
+ (`enabled: forceRender || !nested`). Without it a sheet opened from
53
+ inside another sheet has no backdrop, so nothing covers the parent
54
+ popup — which Base UI has marked inert. Clicks there are swallowed by
55
+ `inert` and never reach the dismiss logic, so the nested sheet cannot
56
+ be closed by clicking outside it. Non-nested sheets are unaffected. */}
57
+ <SheetOverlay forceRender />
52
58
  <SheetPrimitive.Popup
53
59
  data-slot="sheet-content"
54
60
  data-side={side}