From 557011977d26120bbeb57253068c59ee483a025f Mon Sep 17 00:00:00 2001 From: Keith Broughton Date: Wed, 11 Mar 2026 21:34:56 +1100 Subject: [PATCH 1/2] 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 --- packages/auth-next-client/package.json | 3 +- packages/auth-next-client/src/hooks.test.tsx | 79 +++++++++++++++++++ packages/auth-next-client/src/hooks.tsx | 13 ++- .../src/useStableValue.test.ts | 75 ++++++++++++++++++ .../auth-next-client/src/useStableValue.ts | 19 +++++ pnpm-lock.yaml | 3 + 6 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 packages/auth-next-client/src/useStableValue.test.ts create mode 100644 packages/auth-next-client/src/useStableValue.ts diff --git a/packages/auth-next-client/package.json b/packages/auth-next-client/package.json index 100b793813..6ae12a94ec 100644 --- a/packages/auth-next-client/package.json +++ b/packages/auth-next-client/package.json @@ -37,7 +37,8 @@ }, "dependencies": { "@imtbl/auth": "workspace:*", - "@imtbl/auth-next-server": "workspace:*" + "@imtbl/auth-next-server": "workspace:*", + "fast-json-stable-stringify": "^2.1.0" }, "peerDependencies": { "next": "^14.0.0 || ^15.0.0", diff --git a/packages/auth-next-client/src/hooks.test.tsx b/packages/auth-next-client/src/hooks.test.tsx index 614bd7df21..d87fc4332e 100644 --- a/packages/auth-next-client/src/hooks.test.tsx +++ b/packages/auth-next-client/src/hooks.test.tsx @@ -310,6 +310,85 @@ describe('useImmutableSession', () => { }); }); + describe('session reference stability', () => { + it('returns same reference when useSession returns new object with identical data', () => { + const sessionData = createSession(); + setupUseSession(sessionData); + mockUpdate.mockResolvedValue(sessionData); + + const { result, rerender } = renderHook(() => useImmutableSession()); + + const firstRef = result.current.session; + + // Simulate window-focus refetch: useSession returns a new object with identical data + setupUseSession(createSession()); + rerender(); + + expect(result.current.session).toBe(firstRef); + }); + + it('returns new reference when accessToken changes', () => { + const sessionData = createSession(); + setupUseSession(sessionData); + mockUpdate.mockResolvedValue(sessionData); + + const { result, rerender } = renderHook(() => useImmutableSession()); + + const firstRef = result.current.session; + + // Simulate token refresh: new accessToken + setupUseSession(createSession({ accessToken: 'new-token' })); + rerender(); + + expect(result.current.session).not.toBe(firstRef); + }); + + it('returns new reference when error appears', () => { + const sessionData = createSession(); + setupUseSession(sessionData); + mockUpdate.mockResolvedValue(sessionData); + + const { result, rerender } = renderHook(() => useImmutableSession()); + + const firstRef = result.current.session; + + // Simulate refresh failure: error field added + setupUseSession(createSession({ error: 'RefreshTokenError' })); + rerender(); + + expect(result.current.session).not.toBe(firstRef); + }); + + it('returns new reference when going from null to session', () => { + setupUseSession(null, 'unauthenticated'); + mockUpdate.mockResolvedValue(null); + + const { result, rerender } = renderHook(() => useImmutableSession()); + + expect(result.current.session).toBeNull(); + + setupUseSession(createSession()); + rerender(); + + expect(result.current.session).not.toBeNull(); + }); + + it('returns new reference when going from session to null', () => { + const sessionData = createSession(); + setupUseSession(sessionData); + mockUpdate.mockResolvedValue(sessionData); + + const { result, rerender } = renderHook(() => useImmutableSession()); + + expect(result.current.session).not.toBeNull(); + + setupUseSession(null, 'unauthenticated'); + rerender(); + + expect(result.current.session).toBeNull(); + }); + }); + describe('getUser() respects pending refresh', () => { it('waits for in-flight refresh before returning user', async () => { const expiredSession = createSession({ diff --git a/packages/auth-next-client/src/hooks.tsx b/packages/auth-next-client/src/hooks.tsx index 201ebfc281..3fbe6a9f1e 100644 --- a/packages/auth-next-client/src/hooks.tsx +++ b/packages/auth-next-client/src/hooks.tsx @@ -29,6 +29,7 @@ import { DEFAULT_AUDIENCE, } from './constants'; import { storeIdToken, getStoredIdToken, clearStoredIdToken } from './idTokenStorage'; +import { useStableValue } from './useStableValue'; // --------------------------------------------------------------------------- // Module-level deduplication for session refresh @@ -366,9 +367,19 @@ export function useImmutableSession(): UseImmutableSessionReturn { return refreshed.accessToken; }, []); // Empty deps -- uses refs for latest values + // Stable session reference for consumers. + // + // next-auth's SessionProvider refetches the session on every window focus + // (refetchOnWindowFocus defaults to true). Each refetch returns a new object + // even when nothing has changed, which causes unnecessary re-renders and + // effect re-runs for any consumer using session in deps or as a prop. + // See: https://github.com/nextauthjs/next-auth/issues/3405 + // // Cast to public type (omits accessToken) to prevent consumers from // accidentally using a potentially stale token. Use getAccessToken() instead. - const publicSession = session as ImmutableSession | null; + // sessionRef (above) still tracks the raw latest for imperative use by + // getUser/getAccessToken. + const publicSession = useStableValue(session as ImmutableSession | null); return { session: publicSession, diff --git a/packages/auth-next-client/src/useStableValue.test.ts b/packages/auth-next-client/src/useStableValue.test.ts new file mode 100644 index 0000000000..b0ab007ac8 --- /dev/null +++ b/packages/auth-next-client/src/useStableValue.test.ts @@ -0,0 +1,75 @@ +import { renderHook } from '@testing-library/react'; +import { useStableValue } from './useStableValue'; + +describe('useStableValue', () => { + it('returns same reference when value is deeply equal', () => { + const initial = { a: 1, b: 'hello', nested: { x: true } }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: initial } }, + ); + + const firstRef = result.current; + + // Re-render with a new object that has identical data + rerender({ value: { a: 1, b: 'hello', nested: { x: true } } }); + + expect(result.current).toBe(firstRef); + }); + + it('returns new reference when value changes', () => { + const initial = { a: 1, b: 'hello' }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: initial } }, + ); + + const firstRef = result.current; + + rerender({ value: { a: 2, b: 'hello' } }); + + expect(result.current).not.toBe(firstRef); + expect(result.current).toEqual({ a: 2, b: 'hello' }); + }); + + it('handles null to value transition', () => { + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: null as { a: number } | null } }, + ); + + expect(result.current).toBeNull(); + + rerender({ value: { a: 1 } }); + + expect(result.current).toEqual({ a: 1 }); + }); + + it('handles value to null transition', () => { + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: { a: 1 } as { a: number } | null } }, + ); + + expect(result.current).toEqual({ a: 1 }); + + rerender({ value: null }); + + expect(result.current).toBeNull(); + }); + + it('is key-order independent', () => { + const initial = { b: 2, a: 1 }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: initial } }, + ); + + const firstRef = result.current; + + // Same data, different key order + rerender({ value: { a: 1, b: 2 } }); + + expect(result.current).toBe(firstRef); + }); +}); diff --git a/packages/auth-next-client/src/useStableValue.ts b/packages/auth-next-client/src/useStableValue.ts new file mode 100644 index 0000000000..7f66518133 --- /dev/null +++ b/packages/auth-next-client/src/useStableValue.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useMemo } from 'react'; +import stableStringify from 'fast-json-stable-stringify'; + +/** + * Returns a referentially stable version of the given value. + * + * The reference only changes when the value's serialized representation + * changes (deep, key-order-independent comparison via stable JSON stringify). + * + * Useful for wrapping values from contexts or external sources that produce + * new object references on every read even when the data is unchanged + * (e.g., next-auth's useSession on window focus refetch). + */ +export function useStableValue(value: T): T { + const key = stableStringify(value); + return useMemo(() => value, [key]); // eslint-disable-line -- deps intentionally use serialized key, not value +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69889c9915..bb84a52635 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,6 +1054,9 @@ importers: '@imtbl/auth-next-server': specifier: workspace:* version: link:../auth-next-server + fast-json-stable-stringify: + specifier: ^2.1.0 + version: 2.1.0 devDependencies: '@swc/core': specifier: ^1.4.2 From fbe3f5e7ed4643afb59aba495f5686517fa452b2 Mon Sep 17 00:00:00 2001 From: Keith Broughton Date: Thu, 12 Mar 2026 16:54:52 +1100 Subject: [PATCH 2/2] fix(auth-next-client): pin timestamps in session stability test to avoid flaky CI createSession() calls Date.now() internally; calling it twice could produce different timestamps if the millisecond ticks over, causing useStableValue to see a changed object and breaking the toBe assertion. Made-with: Cursor --- packages/auth-next-client/src/hooks.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/auth-next-client/src/hooks.test.tsx b/packages/auth-next-client/src/hooks.test.tsx index d87fc4332e..0db3729914 100644 --- a/packages/auth-next-client/src/hooks.test.tsx +++ b/packages/auth-next-client/src/hooks.test.tsx @@ -320,8 +320,12 @@ describe('useImmutableSession', () => { const firstRef = result.current.session; - // Simulate window-focus refetch: useSession returns a new object with identical data - setupUseSession(createSession()); + // Simulate window-focus refetch: useSession returns a new object with identical data. + // Pin timestamps from the first session to avoid ms drift between Date.now() calls. + setupUseSession(createSession({ + accessTokenExpires: sessionData.accessTokenExpires, + expires: sessionData.expires, + })); rerender(); expect(result.current.session).toBe(firstRef);