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..0db3729914 100644 --- a/packages/auth-next-client/src/hooks.test.tsx +++ b/packages/auth-next-client/src/hooks.test.tsx @@ -310,6 +310,89 @@ 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. + // 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); + }); + + 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