From f971cd27cd8d0de91907a61dc3e675ff5d291dbf Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Mon, 9 Jun 2025 17:02:40 -0400 Subject: [PATCH 1/2] chore: exclude test files from packaging --- .npmignore | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..6794fce --- /dev/null +++ b/.npmignore @@ -0,0 +1,9 @@ +*.log +.DS_Store +node_modules +.cache +dist +dist-next +coverage +*.tgz +*.test* \ No newline at end of file From 2882bb073556124eefec204229eea947ebabbba8 Mon Sep 17 00:00:00 2001 From: Matt Hamann Date: Thu, 22 Jan 2026 11:49:49 -0500 Subject: [PATCH 2/2] fix(auth): inadvertent page reloads during token refresh --- .../HubScriptInjector/HubScriptInjector.tsx | 11 +- src/context/RowndContext.tsx | 6 + .../components/RowndServerStateSync.test.tsx | 155 ++++++++++++++---- .../components/RowndServerStateSync.tsx | 32 +++- 4 files changed, 168 insertions(+), 36 deletions(-) diff --git a/src/context/HubScriptInjector/HubScriptInjector.tsx b/src/context/HubScriptInjector/HubScriptInjector.tsx index 7fa58be..977ef62 100644 --- a/src/context/HubScriptInjector/HubScriptInjector.tsx +++ b/src/context/HubScriptInjector/HubScriptInjector.tsx @@ -15,11 +15,18 @@ function setConfigValue(key: string, value: any) { window?._rphConfig.push([key, value]); } +/** + * Default API version for new SDK releases. + * This controls which Hub features are enabled by default. + */ +const DEFAULT_API_VERSION = '2026-01-21'; + export type HubScriptInjectorProps = { appKey: string; stateListener: ({ state, api }: HubListenerProps) => void; hubUrlOverride?: string; locationHash?: string; + apiVersion?: string; }; // Grab the URL hash ASAP in case it contains an `rph_init` param @@ -30,6 +37,7 @@ export default function HubScriptInjector({ appKey, hubUrlOverride, stateListener, + apiVersion = DEFAULT_API_VERSION, ...rest }: HubScriptInjectorProps) { @@ -66,6 +74,7 @@ export default function HubScriptInjector({ setConfigValue('setAppKey', appKey); setConfigValue('setStateListener', stateListener); setConfigValue('setLocationHash', locationHash); + setConfigValue('setApiVersion', apiVersion); if (window.localStorage.getItem('rph_log_level') === 'debug') { console.debug('[debug] rest:', rest); @@ -83,7 +92,7 @@ export default function HubScriptInjector({ console.debug('[debug] hubConfig:', window._rphConfig); } } - }, [appKey, stateListener, locationHash, hubUrlOverride, rest]); + }, [appKey, stateListener, locationHash, hubUrlOverride, apiVersion, rest]); return null; } diff --git a/src/context/RowndContext.tsx b/src/context/RowndContext.tsx index 0eab78d..b0cab87 100644 --- a/src/context/RowndContext.tsx +++ b/src/context/RowndContext.tsx @@ -15,6 +15,12 @@ export type RowndProviderProps = { hubUrlOverride?: string; postRegistrationUrl?: string; postSignOutRedirect?: string; + /** + * API version date string (e.g., '2026-01-21') that controls which Hub features are enabled. + * Defaults to the current SDK version date for new features. + * Set to an earlier date to opt-out of newer behaviors. + */ + apiVersion?: string; children: React.ReactNode; }; diff --git a/src/next/client/components/RowndServerStateSync.test.tsx b/src/next/client/components/RowndServerStateSync.test.tsx index 97fb6c9..62feb3e 100644 --- a/src/next/client/components/RowndServerStateSync.test.tsx +++ b/src/next/client/components/RowndServerStateSync.test.tsx @@ -12,13 +12,14 @@ vi.mock('../../../ssr/hooks/useCookie'); describe('RowndServerStateSync', () => { const mockCookieSignIn = vi.fn(); const mockCookieSignOut = vi.fn(); + let mockReload: ReturnType; beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); // Mock window.location.reload - const mockReload = vi.fn(); + mockReload = vi.fn(); Object.defineProperty(window, 'location', { value: { reload: mockReload }, writable: true @@ -43,53 +44,143 @@ describe('RowndServerStateSync', () => { expect(mockCookieSignOut).not.toHaveBeenCalled(); }); - it('should trigger cookieSignIn when access token becomes available', () => { - (useRownd as any).mockReturnValue({ - access_token: 'new-token', - is_initializing: false + describe('initial load (first render after initialization)', () => { + it('should sync cookie without reload when token exists on initial load', () => { + (useRownd as any).mockReturnValue({ + access_token: 'existing-token', + is_initializing: false + }); + + render(); + + // Should sync cookie + expect(mockCookieSignIn).toHaveBeenCalledTimes(1); + // Should NOT pass a reload callback - just sync silently + expect(mockCookieSignIn).toHaveBeenCalledWith(); + expect(mockCookieSignOut).not.toHaveBeenCalled(); }); - render(); + it('should not trigger any actions when no token on initial load', () => { + (useRownd as any).mockReturnValue({ + access_token: null, + is_initializing: false + }); - expect(mockCookieSignIn).toHaveBeenCalled(); - expect(mockCookieSignOut).not.toHaveBeenCalled(); - }); + render(); - it('should trigger cookieSignOut when access token is removed', () => { - (useRownd as any).mockReturnValue({ - access_token: null, - is_initializing: false + // No token on initial load means user wasn't signed in - nothing to sync + expect(mockCookieSignIn).not.toHaveBeenCalled(); + expect(mockCookieSignOut).not.toHaveBeenCalled(); }); + }); - render(); + describe('sign-in (null -> token)', () => { + it('should trigger cookieSignIn with reload callback when signing in', () => { + const mockUseRownd = useRownd as any; - expect(mockCookieSignOut).toHaveBeenCalled(); - expect(mockCookieSignIn).not.toHaveBeenCalled(); + // Initial render with no token + mockUseRownd.mockReturnValue({ + access_token: null, + is_initializing: false + }); + + const { rerender } = render(); + vi.clearAllMocks(); + + // User signs in - token becomes available + mockUseRownd.mockReturnValue({ + access_token: 'new-token', + is_initializing: false + }); + + rerender(); + + expect(mockCookieSignIn).toHaveBeenCalledTimes(1); + // Should pass a reload callback for sign-in + expect(mockCookieSignIn).toHaveBeenCalledWith(expect.any(Function)); + expect(mockCookieSignOut).not.toHaveBeenCalled(); + }); }); - it('should not trigger any cookie actions when access token remains the same', () => { - const mockUseRownd = useRownd as any; + describe('token refresh (token -> different token)', () => { + it('should sync cookie silently without reload on token refresh', () => { + const mockUseRownd = useRownd as any; + + // Initial render with a token + mockUseRownd.mockReturnValue({ + access_token: 'original-token', + is_initializing: false + }); - // Initial render with a token - mockUseRownd.mockReturnValue({ - access_token: 'same-token', - is_initializing: false + const { rerender } = render(); + vi.clearAllMocks(); + + // Token refreshes - different token + mockUseRownd.mockReturnValue({ + access_token: 'refreshed-token', + is_initializing: false + }); + + rerender(); + + // Should sync cookie silently (no reload callback) + expect(mockCookieSignIn).toHaveBeenCalledTimes(1); + expect(mockCookieSignIn).toHaveBeenCalledWith(); + expect(mockCookieSignOut).not.toHaveBeenCalled(); }); + }); - const { rerender } = render(); + describe('sign-out (token -> null)', () => { + it('should trigger cookieSignOut with reload callback when signing out', () => { + const mockUseRownd = useRownd as any; - // Clear the mock calls from initial render - vi.clearAllMocks(); + // Initial render with a token + mockUseRownd.mockReturnValue({ + access_token: 'existing-token', + is_initializing: false + }); + + const { rerender } = render(); + vi.clearAllMocks(); + + // User signs out - token removed + mockUseRownd.mockReturnValue({ + access_token: null, + is_initializing: false + }); + + rerender(); - // Rerender with the same token - mockUseRownd.mockReturnValue({ - access_token: 'same-token', - is_initializing: false + expect(mockCookieSignOut).toHaveBeenCalledTimes(1); + // Should pass a reload callback for sign-out + expect(mockCookieSignOut).toHaveBeenCalledWith(expect.any(Function)); + expect(mockCookieSignIn).not.toHaveBeenCalled(); }); + }); - rerender(); + describe('no change (same token)', () => { + it('should not trigger any cookie actions when access token remains the same', () => { + const mockUseRownd = useRownd as any; - expect(mockCookieSignIn).not.toHaveBeenCalled(); - expect(mockCookieSignOut).not.toHaveBeenCalled(); + // Initial render with a token + mockUseRownd.mockReturnValue({ + access_token: 'same-token', + is_initializing: false + }); + + const { rerender } = render(); + vi.clearAllMocks(); + + // Rerender with the same token + mockUseRownd.mockReturnValue({ + access_token: 'same-token', + is_initializing: false + }); + + rerender(); + + expect(mockCookieSignIn).not.toHaveBeenCalled(); + expect(mockCookieSignOut).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/next/client/components/RowndServerStateSync.tsx b/src/next/client/components/RowndServerStateSync.tsx index d82d320..0a5cc42 100644 --- a/src/next/client/components/RowndServerStateSync.tsx +++ b/src/next/client/components/RowndServerStateSync.tsx @@ -10,9 +10,22 @@ const RowndServerStateSync = () => { // Trigger cookieSignIn when new accessToken is available. const prevAccessToken = useRef(undefined); + const hasInitialized = useRef(false); + useEffect(() => { if (is_initializing) { + return; + } + + // Track initialization state + if (!hasInitialized.current) { + hasInitialized.current = true; prevAccessToken.current = access_token; + + // If we have a token on initial load, sync it without reload + if (access_token) { + cookieSignIn(); + } return; } @@ -20,15 +33,28 @@ const RowndServerStateSync = () => { return; } + const wasSignedOut = !prevAccessToken.current; + const isSigningIn = wasSignedOut && !!access_token; + const isSigningOut = !!prevAccessToken.current && !access_token; + const isTokenRefresh = !!prevAccessToken.current && !!access_token; + prevAccessToken.current = access_token; - if (access_token) { + if (isSigningIn) { + // User just signed in - sync cookie and reload + // This ensures server has the cookie for initial authenticated render cookieSignIn(() => window.location.reload()); return; } - // Handle sign out - if (!access_token) { + if (isTokenRefresh) { + // Token was refreshed in background - sync cookie silently, no reload + cookieSignIn(); + return; + } + + if (isSigningOut) { + // User signed out - sync cookie and reload cookieSignOut(() => window.location.reload()); } }, [