Skip to content
Merged
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
128 changes: 128 additions & 0 deletions src/components/registration-form/__tests__/registration-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,131 @@ it('handleCloseClick calls clearWidgetState and changeStep when no reservation',
expect(mockChangeStep).toHaveBeenCalledWith(STEP_SELECT_TICKET_TYPE);
expect(mockClearWidgetState).toHaveBeenCalled();
});

// Regression tests for the post-purchase remount loop. Effects must depend
// on the semantic signals (summitId, isAuthenticated, userId), not on the
// profileData / summitData object references — consumers commonly mint new
// references on no-op refreshes, and re-firing the effects would cascade
// through handleGetTicketTypesAndTaxes → setHasTicketData → null-render
// guard → child unmount → mount-effects re-firing.

it('does not refetch ticket types when profileData reference changes but content is identical', async () => {
const initialProfile = { id: 42, given_name: 'John', email: 'john@example.com' };
const summitData = { id: 1, name: 'Test Summit', time_zone_id: 'UTC' };
const store = createTestStore();
const closeHandlerRef = React.createRef();
closeHandlerRef.current = () => {};

const { rerender } = render(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={summitData}
profileData={initialProfile}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

expect(mockGetTicketTypesAndTaxes).toHaveBeenCalledTimes(1);
mockGetTicketTypesAndTaxes.mockClear();

// New object identity, same semantic content (same id, same auth state).
const newProfileSameContent = { id: 42, given_name: 'John', email: 'john@example.com' };
rerender(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={summitData}
profileData={newProfileSameContent}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

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

it('does not re-dispatch loadProfileData / discoverPromoCodes when profileData reference changes but userId is the same', async () => {
const initialProfile = { id: 42, given_name: 'John' };
const summitData = { id: 1, name: 'Test Summit', time_zone_id: 'UTC' };
const store = createTestStore();
const closeHandlerRef = React.createRef();
closeHandlerRef.current = () => {};

const { rerender } = render(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={summitData}
profileData={initialProfile}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

expect(mockLoadProfileData).toHaveBeenCalledTimes(1);
expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1);
mockLoadProfileData.mockClear();
mockDiscoverPromoCodes.mockClear();

const newProfileSameId = { id: 42, given_name: 'John' };
rerender(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={summitData}
profileData={newProfileSameId}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

expect(mockLoadProfileData).not.toHaveBeenCalled();
expect(mockDiscoverPromoCodes).not.toHaveBeenCalled();
});

it('refires ticket-types fetch and promo discovery when summit id changes', async () => {
const profileData = { id: 42, given_name: 'John' };
const store = createTestStore();
const closeHandlerRef = React.createRef();
closeHandlerRef.current = () => {};

const { rerender } = render(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={{ id: 1, name: 'Summit One', time_zone_id: 'UTC' }}
profileData={profileData}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

expect(mockGetTicketTypesAndTaxes).toHaveBeenCalledTimes(1);
expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1);
mockGetTicketTypesAndTaxes.mockClear();
mockDiscoverPromoCodes.mockClear();

rerender(
<Provider store={store}>
<RegistrationForm
{...defaultProps}
summitData={{ id: 2, name: 'Summit Two', time_zone_id: 'UTC' }}
profileData={profileData}
closeHandlerRef={closeHandlerRef}
/>
</Provider>
);
await act(async () => {});

expect(mockGetTicketTypesAndTaxes).toHaveBeenCalledTimes(1);
expect(mockGetTicketTypesAndTaxes).toHaveBeenCalledWith(2);
expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1);
expect(mockDiscoverPromoCodes).toHaveBeenCalledWith(2);
});
44 changes: 23 additions & 21 deletions src/components/registration-form/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,14 @@ const RegistrationFormContent = (
errors: []
});

const [hasTicketData, setHasTicketData] = useState(false);
const [ticketDataError, setTicketDataError] = useState(false);
const [ticketDataLoaded, setTicketDataLoaded] = useState(false);
const [unappliedCodeWarning, setUnappliedCodeWarning] = useState(null);

const isAuthenticated = !!profileData;
const summitId = summitData?.id;
const userId = profileData?.id;

const { values: formValues, errors: formErrors } = registrationForm;

const mergeFormValues = useCallback((partial) => setRegistrationForm(prev => ({ ...prev, values: { ...prev.values, ...partial } })), []);
Expand All @@ -198,15 +202,15 @@ const RegistrationFormContent = (

const { publicKey, provider } = getCurrentProvider(summitData);

const allowedTicketTypes = ticketDataLoaded ? ticketTypes.filter((tt) => tt.sub_type === TICKET_TYPE_SUBTYPE_PREPAID || (tt.sales_start_date === null && tt.sales_end_date === null) || (nowUtc >= tt.sales_start_date && nowUtc <= tt.sales_end_date)) : [];
const allowedTicketTypes = useMemo(() => hasTicketData ? ticketTypes.filter((tt) => tt.sub_type === TICKET_TYPE_SUBTYPE_PREPAID || (tt.sales_start_date === null && tt.sales_end_date === null) || (nowUtc >= tt.sales_start_date && nowUtc <= tt.sales_end_date)) : [], [hasTicketData, ticketTypes, nowUtc]);

const noAvailableTickets = useMemo(() => profileData && ticketDataLoaded && !ticketDataError && allowedTicketTypes.length === 0 && step !== STEP_COMPLETE, [profileData, ticketDataLoaded, ticketDataError, allowedTicketTypes, step]);
const alreadyOwnedTickets = useMemo(() => profileData && ticketDataLoaded && !ticketDataError && allowedTicketTypes.length > 0 && ownedTickets.length > 0, [profileData, ticketDataLoaded, ticketDataError, allowedTicketTypes, ownedTickets]);
const noAvailableTickets = useMemo(() => isAuthenticated && hasTicketData && !ticketDataError && allowedTicketTypes.length === 0 && step !== STEP_COMPLETE, [isAuthenticated, hasTicketData, ticketDataError, allowedTicketTypes, step]);
const alreadyOwnedTickets = useMemo(() => isAuthenticated && hasTicketData && !ticketDataError && allowedTicketTypes.length > 0 && ownedTickets.length > 0, [isAuthenticated, hasTicketData, ticketDataError, allowedTicketTypes, ownedTickets]);

useEffect(() => {
if (profileData)
loadProfileData(profileData);
}, [profileData])
}, [userId])

useEffect(() => {
loadSession({ ...rest, summitData, profileData });
Expand All @@ -216,17 +220,17 @@ const RegistrationFormContent = (
}, [])

useEffect(() => {
if (summitData && profileData) {
if (summitId && isAuthenticated) {
const ensureInvitation = () =>
summitData.invite_only_registration
? getMyInvitation(summitData.id)
? getMyInvitation(summitId)
: Promise.resolve();

ensureInvitation()
.catch(e => console.log(e))
.finally(() => handleGetTicketTypesAndTaxes(summitData.id));
.finally(() => handleGetTicketTypesAndTaxes(summitId));
}
}, [summitData?.id, profileData]);
}, [summitId, isAuthenticated]);

useEffect(() => {
if (step > STEP_SELECT_TICKET_TYPE && !registrationForm.values?.ticketType && !reservation) {
Expand All @@ -240,10 +244,10 @@ const RegistrationFormContent = (

// Discovery: fetch qualifying promo codes after auth
useEffect(() => {
if (profileData && summitData?.id) {
discoverPromoCodes(summitData.id);
if (isAuthenticated && summitId) {
discoverPromoCodes(summitId);
}
}, [profileData, summitData?.id]);
}, [isAuthenticated, summitId]);

const handleFormPromoCodeChange = useCallback((code) => mergeFormValues({ promoCode: code }), [mergeFormValues]);

Expand All @@ -256,7 +260,7 @@ const RegistrationFormContent = (
removePromoCode,
validatePromoCode,
setFormPromoCode: handleFormPromoCodeChange,
ticketDataLoaded: ticketDataLoaded && !ticketDataError,
ticketDataLoaded: hasTicketData && !ticketDataError,
hasTickets: allowedTicketTypes.length > 0,
});

Expand Down Expand Up @@ -311,8 +315,10 @@ const RegistrationFormContent = (

const handleGetTicketTypesAndTaxes = (summitId) => {
setTicketDataError(false);
setTicketDataLoaded(false);
getTicketTypesAndTaxes(summitId)
.then(() => {
setHasTicketData(true);
})
.catch((error) => {
let { message } = error;
if (message && (message.includes(AUTH_ERROR_MISSING_AUTH_INFO) ||
Expand All @@ -322,9 +328,6 @@ const RegistrationFormContent = (
return authErrorCallback(error);
}
setTicketDataError(true);
})
.finally(() => {
setTicketDataLoaded(true);
});
}

Expand Down Expand Up @@ -368,10 +371,9 @@ const RegistrationFormContent = (
trackEvent(PURCHASE_COMPLETE, { order });
}

// If user is logged in but ticket data hasn't loaded yet (and no error occurred),
// don't render to avoid flash. Uses local state instead of Redux to prevent
// race conditions with redux-persist rehydration.
if (profileData && !ticketDataLoaded && !ticketDataError) return null;
// Authenticated first-load: wait until ticket data is in before rendering
// to avoid a flash of empty/wrong state.
if (isAuthenticated && !hasTicketData && !ticketDataError) return null;

return (
<div className="summit-registration-lite">
Expand Down
Loading