Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/auth-next-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions packages/auth-next-client/src/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
13 changes: 12 additions & 1 deletion packages/auth-next-client/src/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions packages/auth-next-client/src/useStableValue.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions packages/auth-next-client/src/useStableValue.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T): T {
const key = stableStringify(value);
return useMemo(() => value, [key]); // eslint-disable-line -- deps intentionally use serialized key, not value
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading