Skip to content

Commit 5570119

Browse files
committed
fix(auth-next-client): stabilize session reference across window focus refetches
next-auth's SessionProvider refetches the session on every window focus (refetchOnWindowFocus defaults to true). Each refetch returns a new object reference even when the data is unchanged, causing unnecessary re-renders and effect re-runs for consumers using session in deps or as a prop. This is a known upstream issue (nextauthjs/next-auth#3405) that the maintainers won't fix. Add a reusable useStableValue hook that uses fast-json-stable-stringify to produce a deterministic, key-order-independent string from any value and returns a stable reference via useMemo. Apply it in useImmutableSession so the returned session reference only changes when the data actually changes. sessionRef continues to track the raw latest session for imperative use by getUser/getAccessToken. Made-with: Cursor
1 parent 812c9a4 commit 5570119

6 files changed

Lines changed: 190 additions & 2 deletions

File tree

packages/auth-next-client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
},
3838
"dependencies": {
3939
"@imtbl/auth": "workspace:*",
40-
"@imtbl/auth-next-server": "workspace:*"
40+
"@imtbl/auth-next-server": "workspace:*",
41+
"fast-json-stable-stringify": "^2.1.0"
4142
},
4243
"peerDependencies": {
4344
"next": "^14.0.0 || ^15.0.0",

packages/auth-next-client/src/hooks.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,85 @@ describe('useImmutableSession', () => {
310310
});
311311
});
312312

313+
describe('session reference stability', () => {
314+
it('returns same reference when useSession returns new object with identical data', () => {
315+
const sessionData = createSession();
316+
setupUseSession(sessionData);
317+
mockUpdate.mockResolvedValue(sessionData);
318+
319+
const { result, rerender } = renderHook(() => useImmutableSession());
320+
321+
const firstRef = result.current.session;
322+
323+
// Simulate window-focus refetch: useSession returns a new object with identical data
324+
setupUseSession(createSession());
325+
rerender();
326+
327+
expect(result.current.session).toBe(firstRef);
328+
});
329+
330+
it('returns new reference when accessToken changes', () => {
331+
const sessionData = createSession();
332+
setupUseSession(sessionData);
333+
mockUpdate.mockResolvedValue(sessionData);
334+
335+
const { result, rerender } = renderHook(() => useImmutableSession());
336+
337+
const firstRef = result.current.session;
338+
339+
// Simulate token refresh: new accessToken
340+
setupUseSession(createSession({ accessToken: 'new-token' }));
341+
rerender();
342+
343+
expect(result.current.session).not.toBe(firstRef);
344+
});
345+
346+
it('returns new reference when error appears', () => {
347+
const sessionData = createSession();
348+
setupUseSession(sessionData);
349+
mockUpdate.mockResolvedValue(sessionData);
350+
351+
const { result, rerender } = renderHook(() => useImmutableSession());
352+
353+
const firstRef = result.current.session;
354+
355+
// Simulate refresh failure: error field added
356+
setupUseSession(createSession({ error: 'RefreshTokenError' }));
357+
rerender();
358+
359+
expect(result.current.session).not.toBe(firstRef);
360+
});
361+
362+
it('returns new reference when going from null to session', () => {
363+
setupUseSession(null, 'unauthenticated');
364+
mockUpdate.mockResolvedValue(null);
365+
366+
const { result, rerender } = renderHook(() => useImmutableSession());
367+
368+
expect(result.current.session).toBeNull();
369+
370+
setupUseSession(createSession());
371+
rerender();
372+
373+
expect(result.current.session).not.toBeNull();
374+
});
375+
376+
it('returns new reference when going from session to null', () => {
377+
const sessionData = createSession();
378+
setupUseSession(sessionData);
379+
mockUpdate.mockResolvedValue(sessionData);
380+
381+
const { result, rerender } = renderHook(() => useImmutableSession());
382+
383+
expect(result.current.session).not.toBeNull();
384+
385+
setupUseSession(null, 'unauthenticated');
386+
rerender();
387+
388+
expect(result.current.session).toBeNull();
389+
});
390+
});
391+
313392
describe('getUser() respects pending refresh', () => {
314393
it('waits for in-flight refresh before returning user', async () => {
315394
const expiredSession = createSession({

packages/auth-next-client/src/hooks.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
DEFAULT_AUDIENCE,
3030
} from './constants';
3131
import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage';
32+
import { useStableValue } from './useStableValue';
3233

3334
// ---------------------------------------------------------------------------
3435
// Module-level deduplication for session refresh
@@ -366,9 +367,19 @@ export function useImmutableSession(): UseImmutableSessionReturn {
366367
return refreshed.accessToken;
367368
}, []); // Empty deps -- uses refs for latest values
368369

370+
// Stable session reference for consumers.
371+
//
372+
// next-auth's SessionProvider refetches the session on every window focus
373+
// (refetchOnWindowFocus defaults to true). Each refetch returns a new object
374+
// even when nothing has changed, which causes unnecessary re-renders and
375+
// effect re-runs for any consumer using session in deps or as a prop.
376+
// See: https://github.com/nextauthjs/next-auth/issues/3405
377+
//
369378
// Cast to public type (omits accessToken) to prevent consumers from
370379
// accidentally using a potentially stale token. Use getAccessToken() instead.
371-
const publicSession = session as ImmutableSession | null;
380+
// sessionRef (above) still tracks the raw latest for imperative use by
381+
// getUser/getAccessToken.
382+
const publicSession = useStableValue(session as ImmutableSession | null);
372383

373384
return {
374385
session: publicSession,
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { renderHook } from '@testing-library/react';
2+
import { useStableValue } from './useStableValue';
3+
4+
describe('useStableValue', () => {
5+
it('returns same reference when value is deeply equal', () => {
6+
const initial = { a: 1, b: 'hello', nested: { x: true } };
7+
const { result, rerender } = renderHook(
8+
({ value }) => useStableValue(value),
9+
{ initialProps: { value: initial } },
10+
);
11+
12+
const firstRef = result.current;
13+
14+
// Re-render with a new object that has identical data
15+
rerender({ value: { a: 1, b: 'hello', nested: { x: true } } });
16+
17+
expect(result.current).toBe(firstRef);
18+
});
19+
20+
it('returns new reference when value changes', () => {
21+
const initial = { a: 1, b: 'hello' };
22+
const { result, rerender } = renderHook(
23+
({ value }) => useStableValue(value),
24+
{ initialProps: { value: initial } },
25+
);
26+
27+
const firstRef = result.current;
28+
29+
rerender({ value: { a: 2, b: 'hello' } });
30+
31+
expect(result.current).not.toBe(firstRef);
32+
expect(result.current).toEqual({ a: 2, b: 'hello' });
33+
});
34+
35+
it('handles null to value transition', () => {
36+
const { result, rerender } = renderHook(
37+
({ value }) => useStableValue(value),
38+
{ initialProps: { value: null as { a: number } | null } },
39+
);
40+
41+
expect(result.current).toBeNull();
42+
43+
rerender({ value: { a: 1 } });
44+
45+
expect(result.current).toEqual({ a: 1 });
46+
});
47+
48+
it('handles value to null transition', () => {
49+
const { result, rerender } = renderHook(
50+
({ value }) => useStableValue(value),
51+
{ initialProps: { value: { a: 1 } as { a: number } | null } },
52+
);
53+
54+
expect(result.current).toEqual({ a: 1 });
55+
56+
rerender({ value: null });
57+
58+
expect(result.current).toBeNull();
59+
});
60+
61+
it('is key-order independent', () => {
62+
const initial = { b: 2, a: 1 };
63+
const { result, rerender } = renderHook(
64+
({ value }) => useStableValue(value),
65+
{ initialProps: { value: initial } },
66+
);
67+
68+
const firstRef = result.current;
69+
70+
// Same data, different key order
71+
rerender({ value: { a: 1, b: 2 } });
72+
73+
expect(result.current).toBe(firstRef);
74+
});
75+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { useMemo } from 'react';
4+
import stableStringify from 'fast-json-stable-stringify';
5+
6+
/**
7+
* Returns a referentially stable version of the given value.
8+
*
9+
* The reference only changes when the value's serialized representation
10+
* changes (deep, key-order-independent comparison via stable JSON stringify).
11+
*
12+
* Useful for wrapping values from contexts or external sources that produce
13+
* new object references on every read even when the data is unchanged
14+
* (e.g., next-auth's useSession on window focus refetch).
15+
*/
16+
export function useStableValue<T>(value: T): T {
17+
const key = stableStringify(value);
18+
return useMemo(() => value, [key]); // eslint-disable-line -- deps intentionally use serialized key, not value
19+
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)