Skip to content
9 changes: 7 additions & 2 deletions src/context/ReactRowndProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React, { useCallback, useEffect } from 'react';
import { RowndContext, RowndProviderProps } from './RowndContext';
import {
HubListenerProps,
RowndContext,
RowndProviderProps,
} from './RowndContext';
import InternalProviderHubScriptInjector from './HubScriptInjector/InternalProviderHubScriptInjector';
import useHub from '../hooks/useHub';
import { TRowndContext, UserDataContext } from './types';
Expand Down Expand Up @@ -40,6 +44,7 @@ export const ReactRowndProvider: React.FC<RowndProviderProps> = ({
const { user, is_authenticated, is_initializing } = hubState;
useSuperTokensMigration({
accessToken: hubState.access_token,
authLevel: hubState.auth_level,
events: hubState.events,
supertokens: props.supertokens,
});
Expand All @@ -55,7 +60,7 @@ export const ReactRowndProvider: React.FC<RowndProviderProps> = ({
}, [is_authenticated, is_initializing, user.data.user_id]);

const stateListener = useCallback(
({ state, api }) => {
({ state, api }: HubListenerProps) => {
hubListenerCb({ state, api, callback: setHubState });
},
[hubListenerCb]
Expand Down
141 changes: 139 additions & 2 deletions src/hooks/useSuperTokensMigration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ describe('useSuperTokensMigration', () => {
removeEventListener,
} as unknown as TRowndContext['events'];

function TestComponent() {
function TestComponent({
accessToken = 'rownd-access-token',
authLevel,
}: {
accessToken?: string | null;
authLevel?: TRowndContext['auth_level'];
}) {
useSuperTokensMigration({
accessToken: 'rownd-access-token',
accessToken,
authLevel,
events,
supertokens: {
appInfo: {
Expand Down Expand Up @@ -76,6 +83,136 @@ describe('useSuperTokensMigration', () => {
);
});

it('waits for the access token before syncing new Rownd users', async () => {
const { rerender } = render(<TestComponent accessToken={null} />);

await waitFor(() => {
expect(addEventListener).toHaveBeenCalledWith(
'sign_in_completed',
expect.any(Function)
);
});

await act(async () => {
getSignInCompletedHandler()(new CustomEvent('sign_in_completed', {
detail: { user_type: 'new_user' },
}));
});

expect(syncUserToSuperTokens).not.toHaveBeenCalled();

rerender(<TestComponent accessToken="rownd-access-token" />);

await waitFor(() => {
expect(syncUserToSuperTokens).toHaveBeenCalledWith(
'rownd-access-token',
{
appName: 'test-app',
apiDomain: 'https://api.example.com',
apiBasePath: '/auth',
}
);
});
});

it('syncs converted instant users after receiving a non-instant token', async () => {
const { rerender } = render(
<TestComponent accessToken="instant-token" authLevel="instant" />
);

await waitFor(() => {
expect(addEventListener).toHaveBeenCalledWith(
'sign_in_completed',
expect.any(Function)
);
});

await act(async () => {
getSignInCompletedHandler()(new CustomEvent('sign_in_completed', {
detail: { user_type: 'existing_user' },
}));
});

expect(syncUserToSuperTokens).not.toHaveBeenCalled();

rerender(
<TestComponent accessToken="verified-token" authLevel="verified" />
);

await waitFor(() => {
expect(syncUserToSuperTokens).toHaveBeenCalledWith(
'verified-token',
{
appName: 'test-app',
apiDomain: 'https://api.example.com',
apiBasePath: '/auth',
}
);
});
});

it('syncs converted instant users when verified state arrives before the sign-in event', async () => {
const { rerender } = render(
<TestComponent accessToken="instant-token" authLevel="instant" />
);

await waitFor(() => {
expect(addEventListener).toHaveBeenCalledWith(
'sign_in_completed',
expect.any(Function)
);
});

rerender(
<TestComponent accessToken="verified-token" authLevel="verified" />
);

expect(syncUserToSuperTokens).not.toHaveBeenCalled();

await act(async () => {
getSignInCompletedHandler()(new CustomEvent('sign_in_completed', {
detail: { user_type: 'existing_user' },
}));
});

await waitFor(() => {
expect(syncUserToSuperTokens).toHaveBeenCalledWith(
'verified-token',
{
appName: 'test-app',
apiDomain: 'https://api.example.com',
apiBasePath: '/auth',
}
);
});
});

it('does not carry instant conversion state across sign-out', async () => {
const { rerender } = render(
<TestComponent accessToken="instant-token" authLevel="instant" />
);

await waitFor(() => {
expect(addEventListener).toHaveBeenCalledWith(
'sign_in_completed',
expect.any(Function)
);
});

rerender(<TestComponent accessToken={null} authLevel={undefined} />);
rerender(
<TestComponent accessToken="verified-token" authLevel="verified" />
);

await act(async () => {
getSignInCompletedHandler()(new CustomEvent('sign_in_completed', {
detail: { user_type: 'existing_user' },
}));
});

expect(syncUserToSuperTokens).not.toHaveBeenCalled();
});

it('ignores non-CustomEvent sign-in events', async () => {
render(<TestComponent />);

Expand Down
90 changes: 70 additions & 20 deletions src/hooks/useSuperTokensMigration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { RowndProviderProps } from '../context/RowndContext';
import { TRowndContext } from '../context/types';
import {
Expand All @@ -8,63 +8,113 @@ import {

type UseSuperTokensMigrationProps = {
accessToken: string | null;
authLevel: TRowndContext['auth_level'];
events: TRowndContext['events'];
supertokens?: RowndProviderProps['supertokens'];
};

function isNewUserSignInCompletedEvent(event: Event): boolean {
function getSignInCompletedUserType(event: Event): unknown {
if (typeof CustomEvent === 'undefined' || !(event instanceof CustomEvent)) {
return false;
return undefined;
}

const detail = event.detail;
if (!detail || typeof detail !== 'object') {
return false;
return undefined;
}

return (detail as { user_type?: unknown }).user_type === 'new_user';
return (detail as { user_type?: unknown }).user_type;
}

function shouldMigrateSignIn(
userType: unknown,
hasSeenInstantSession: boolean
): boolean {
return (
userType === 'new_user' ||
(userType === 'existing_user' && hasSeenInstantSession)
);
}

export function useSuperTokensMigration({
accessToken,
authLevel,
events,
supertokens,
}: UseSuperTokensMigrationProps): void {
const appInfo = supertokens?.appInfo;
const accessTokenRef = useRef<string | null>(accessToken);
const supertokensAppInfoRef = useRef(
normalizeSuperTokensAppInfo(appInfo)
);
const authLevelRef = useRef<TRowndContext['auth_level']>(authLevel);
const hasSeenInstantSessionRef = useRef(false);
const pendingMigrationRef = useRef(false);
const supertokensAppInfoRef = useRef(normalizeSuperTokensAppInfo(appInfo));

const flushPendingMigration = useCallback(() => {
const currentAccessToken = accessTokenRef.current;
const currentAuthLevel = authLevelRef.current;
const appInfo = supertokensAppInfoRef.current;

if (
!pendingMigrationRef.current ||
!currentAccessToken ||
!appInfo ||
currentAuthLevel === 'instant'
) {
return;
}

pendingMigrationRef.current = false;
void syncUserToSuperTokens(currentAccessToken, appInfo);
}, []);

useEffect(() => {
const hadAccessToken = !!accessTokenRef.current;

accessTokenRef.current = accessToken;
}, [accessToken]);
authLevelRef.current = authLevel;

if (hadAccessToken && !accessToken) {
hasSeenInstantSessionRef.current = false;
pendingMigrationRef.current = false;
}

if (accessToken && authLevel === 'instant') {
hasSeenInstantSessionRef.current = true;
}

flushPendingMigration();
}, [accessToken, authLevel, flushPendingMigration]);

useEffect(() => {
supertokensAppInfoRef.current = normalizeSuperTokensAppInfo(
appInfo
);
}, [appInfo?.appName, appInfo?.apiDomain, appInfo?.apiBasePath]);
supertokensAppInfoRef.current = normalizeSuperTokensAppInfo(appInfo);
flushPendingMigration();
}, [
appInfo?.appName,
appInfo?.apiDomain,
appInfo?.apiBasePath,
flushPendingMigration,
]);

useEffect(() => {
const handleSignInCompleted = (event: Event) => {
if (!isNewUserSignInCompletedEvent(event)) {
const userType = getSignInCompletedUserType(event);

if (!shouldMigrateSignIn(userType, hasSeenInstantSessionRef.current)) {
return;
}

const currentAccessToken = accessTokenRef.current;
const appInfo = supertokensAppInfoRef.current;
if (!currentAccessToken || !appInfo) {
return;
pendingMigrationRef.current = true;
if (userType === 'existing_user') {
hasSeenInstantSessionRef.current = false;
}

void syncUserToSuperTokens(currentAccessToken, appInfo);
flushPendingMigration();
};

events.addEventListener('sign_in_completed', handleSignInCompleted);

return () => {
events.removeEventListener('sign_in_completed', handleSignInCompleted);
};
}, [events]);
}, [events, flushPendingMigration]);
}
18 changes: 14 additions & 4 deletions src/next/client/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use client';

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { RowndProviderProps } from '../../context/RowndContext';
import {
HubListenerProps,
RowndProviderProps,
} from '../../context/RowndContext';
import { store } from './store';
import HubScriptInjector from '../../context/HubScriptInjector/HubScriptInjector';
import useHub from '../../hooks/useHub';
Expand Down Expand Up @@ -33,11 +36,18 @@ const Client: React.FC<Omit<RowndProviderProps, 'children'>> = (props) => {
[store]
);

const { access_token, events, is_initializing, is_authenticated, user } =
useRownd();
const {
access_token,
auth_level,
events,
is_initializing,
is_authenticated,
user,
} = useRownd();
const { cookieSignIn, cookieSignOut } = useCookie(useRownd);
useSuperTokensMigration({
accessToken: access_token,
authLevel: auth_level,
events,
supertokens: props.supertokens,
});
Expand Down Expand Up @@ -65,7 +75,7 @@ const Client: React.FC<Omit<RowndProviderProps, 'children'>> = (props) => {
}
}, [hasCookieSignedIn, user.data.user_id, is_initializing, is_authenticated]);

const stateListener = useCallback(({ state, api }) => {
const stateListener = useCallback(({ state, api }: HubListenerProps) => {
hubListenerCb({
state,
api,
Expand Down
Loading