From b1b1295f5a68f5e3461ce63139de5503ffd8aefe Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 28 May 2026 11:21:17 -0300 Subject: [PATCH 1/6] fix(clock): stop tick-driven re-renders in registration widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget's Clock component dispatched UPDATE_CLOCK to Redux every second, so every connected component re-rendered on every tick — including TicketDropdownComponent, which only depends on the filtered list of available tickets. Replace the internal Redux clock with uicore 4.2.31's ClockProvider and switch consumers to useClockSelector so the form only re-renders when the derived value actually changes: - registration-form: allowedTicketTypes recomputes every tick but only commits a new array when a sale window opens/closes (shallowEqual) - purchase-complete: isActive only flips when the summit transitions active/inactive Also drops the UPDATE_CLOCK action, reducer case, and nowUtc state field, and renames withReduxProvider to withWidgetProviders since it now hosts both the Redux Provider and the ClockProvider. --- package.json | 4 +- src/actions.js | 4 -- src/components/purchase-complete/index.js | 13 +++++-- .../__tests__/registration-form.test.js | 24 +++++------- src/components/registration-form/index.js | 36 ++++++++++++------ .../__tests__/registration-modal.test.js | 6 +-- src/components/registration-modal/index.js | 4 +- src/reducer.js | 10 ----- ...er.test.js => withWidgetProviders.test.js} | 38 ++++++++++++++----- ...eduxProvider.js => withWidgetProviders.js} | 26 ++++++++----- yarn.lock | 15 ++++++-- 11 files changed, 107 insertions(+), 73 deletions(-) rename src/utils/__tests__/{withReduxProvider.test.js => withWidgetProviders.test.js} (78%) rename src/utils/{withReduxProvider.js => withWidgetProviders.js} (54%) diff --git a/package.json b/package.json index 24c672d..5ff4e10 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "mini-css-extract-plugin": "^2.6.0", "moment": "^2.22.2", "moment-timezone": "^0.5.21", - "openstack-uicore-foundation": "4.2.14", + "openstack-uicore-foundation": "4.2.31", "optimize-css-assets-webpack-plugin": "^6.0.1", "path": "^0.12.7", "react": "^16.8.4", @@ -129,7 +129,7 @@ "lodash": "^4.17.14", "moment": "^2.22.2", "moment-timezone": "^0.5.21", - "openstack-uicore-foundation": "4.2.9", + "openstack-uicore-foundation": "4.2.31", "react": "^16.8.4", "react-bootstrap": "^0.31.5", "react-datetime": "^2.16.2", diff --git a/src/actions.js b/src/actions.js index c412110..4779099 100644 --- a/src/actions.js +++ b/src/actions.js @@ -47,7 +47,6 @@ export const GO_TO_LOGIN = 'GO_TO_LOGIN'; export const GET_MY_INVITATION = 'GET_MY_INVITATION'; export const CLEAR_MY_INVITATION = 'CLEAR_MY_INVITATION'; export const CLEAR_WIDGET_STATE = 'CLEAR_WIDGET_STATE'; -export const UPDATE_CLOCK = 'UPDATE_CLOCK'; export const LOAD_PROFILE_DATA = 'LOAD_PROFILE_DATA'; export const SET_CURRENT_PROMO_CODE = 'SET_CURRENT_PROMO_CODE'; @@ -525,6 +524,3 @@ export const getMyInvitation = (summitId) => async (dispatch, getState, { apiBas } } -export const updateClock = (timestamp) => (dispatch) => { - dispatch(createAction(UPDATE_CLOCK)({ timestamp })); -}; diff --git a/src/components/purchase-complete/index.js b/src/components/purchase-complete/index.js index 16ac756..5293a72 100644 --- a/src/components/purchase-complete/index.js +++ b/src/components/purchase-complete/index.js @@ -11,9 +11,10 @@ * limitations under the License. **/ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import styles from './index.module.scss'; import { epochToMomentTimeZone } from 'openstack-uicore-foundation/lib/utils/methods'; +import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; import ContentLoader from 'react-content-loader'; import { isEmptyString, ticketHasAccessLevel } from '../../utils/utils'; import { VirtualAccessLevel } from '../../utils/constants'; @@ -39,7 +40,6 @@ const PurchaseComplete = ({ goToMyOrders, completedExtraQuestions, summit, - nowUtc, clearWidgetState, closeWidget, supportEmail, @@ -54,7 +54,14 @@ const PurchaseComplete = ({ const [requireExtraQuestions, setRequireExtraQuestions] = useState(null); const [extraQuestionsLoaded, setExtraQuestonsLoaded] = useState(false); const isMultiOrder = useMemo(() => checkout?.tickets.length > 1, [checkout]); - const isActive = useMemo(() => summit.start_date <= nowUtc && summit.end_date >= nowUtc, [summit, nowUtc]); + // Re-runs every clock tick but the boolean only changes on summit + // active/inactive transitions (typically zero times per session). + const isActive = useClockSelector( + useCallback( + (nowUtc) => summit.start_date <= nowUtc && summit.end_date >= nowUtc, + [summit.start_date, summit.end_date] + ) + ); const currentTicket = useMemo( () => isMultiOrder ? checkout?.tickets.find(t => t?.owner?.email === user?.email) : checkout?.tickets.find(t => t?.owner), [user] diff --git a/src/components/registration-form/__tests__/registration-form.test.js b/src/components/registration-form/__tests__/registration-form.test.js index c2cd893..9a02b1e 100644 --- a/src/components/registration-form/__tests__/registration-form.test.js +++ b/src/components/registration-form/__tests__/registration-form.test.js @@ -5,13 +5,19 @@ import { Provider } from 'react-redux'; import { createStore, combineReducers, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; -// Mock withReduxProvider as identity HOC -jest.mock('../../../utils/withReduxProvider', () => ({ - withReduxProvider: (Component) => Component, +// Mock withWidgetProviders as identity HOC +jest.mock('../../../utils/withWidgetProviders', () => ({ + withWidgetProviders: (Component) => Component, __esModule: true, default: (Component) => Component, })); +// Stub the uicore clock-context so the selector runs once against a fixed timestamp +jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({ + useClockSelector: (selector) => selector(1000000), + ClockProvider: ({ children }) => children, +})); + // Mock action creators const mockChangeStep = jest.fn(); const mockClearWidgetState = jest.fn(); @@ -24,7 +30,6 @@ const mockGetLoginCode = jest.fn(); const mockPasswordlessLogin = jest.fn(); const mockGoToLogin = jest.fn(); const mockGetMyInvitation = jest.fn(() => Promise.resolve()); -const mockUpdateClock = jest.fn(); const mockLoadProfileData = jest.fn(); const mockApplyPromoCode = jest.fn(); const mockRemovePromoCode = jest.fn(); @@ -78,10 +83,6 @@ jest.mock('../../../actions', () => ({ mockGetMyInvitation(...args); return Promise.resolve(); }, - updateClock: (...args) => { - mockUpdateClock(...args); - return { type: 'NOOP' }; - }, loadProfileData: (...args) => { mockLoadProfileData(...args); return { type: 'NOOP' }; @@ -117,10 +118,6 @@ jest.mock('openstack-uicore-foundation/lib/components/ajaxloader', () => { return (props) =>
; }); -jest.mock('openstack-uicore-foundation/lib/components/clock', () => { - return (props) =>
; -}); - jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({ AUTH_ERROR_MISSING_AUTH_INFO: 'Missing Auth info', AUTH_ERROR_MISSING_REFRESH_TOKEN: 'missing Refresh Token', @@ -154,7 +151,7 @@ jest.mock('react-use', () => ({ useMeasure: () => [jest.fn(), { height: 100 }], })); -// Import default (which is withReduxProvider(RegistrationForm) but our mock makes it identity) +// Import default (which is withWidgetProviders(RegistrationForm) but our mock makes it identity) import RegistrationForm from '..'; const STEP_SELECT_TICKET_TYPE = 0; @@ -180,7 +177,6 @@ const defaultReduxState = { summitId: null, userProfile: null, }, - nowUtc: 1000000, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, diff --git a/src/components/registration-form/index.js b/src/components/registration-form/index.js index b2f2fcb..6c96be3 100644 --- a/src/components/registration-form/index.js +++ b/src/components/registration-form/index.js @@ -14,9 +14,10 @@ **/ import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { connect } from "react-redux"; +import { connect, shallowEqual } from "react-redux"; import PropTypes from 'prop-types'; -import { withReduxProvider } from '../../utils/withReduxProvider'; +import { withWidgetProviders } from '../../utils/withWidgetProviders'; +import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; import { animated, config, useSpring } from "react-spring"; import { useMeasure } from "react-use"; import { @@ -37,7 +38,6 @@ import { removeReservedTicket, reserveTicket, clearWidgetState, - updateClock, loadProfileData, removePromoCode, applyPromoCode, @@ -50,7 +50,6 @@ import { import usePromoCode from '../../hooks/usePromoCode'; import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader"; -import Clock from "openstack-uicore-foundation/lib/components/clock"; import '../../styles/styles.scss'; @@ -87,6 +86,14 @@ try { T.setTexts(require(`../../i18n/en.json`)); } +const isTicketCurrentlyAvailable = (tt, nowUtc) => + // prepaid tickets are always available (sales windows don't apply) + tt.sub_type === TICKET_TYPE_SUBTYPE_PREPAID || + // no sales window configured → ticket is open-ended, always available + (tt.sales_start_date === null && tt.sales_end_date === null) || + // ticket is within its configured sales window + (nowUtc >= tt.sales_start_date && nowUtc <= tt.sales_end_date); + const RegistrationFormContent = ( { @@ -142,8 +149,6 @@ const RegistrationFormContent = ( allowPromoCodes, showCompanyInput, companyDDLPlaceholder, - nowUtc, - updateClock, completedExtraQuestions, loadProfileData, closeWidget, @@ -200,7 +205,18 @@ const RegistrationFormContent = ( const { publicKey, provider } = getCurrentProvider(summitData); - 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]); + // Re-runs every clock tick but only commits a new array when the filtered + // contents shift (a sale window opens/closes), so the form tree stops + // re-rendering once per second. + const allowedTicketTypes = useClockSelector( + useCallback( + (nowUtc) => hasTicketData + ? ticketTypes.filter(tt => isTicketCurrentlyAvailable(tt, nowUtc)) + : [], + [hasTicketData, ticketTypes] + ), + shallowEqual + ); const alreadyOwnedTickets = useMemo(() => isAuthenticated && hasTicketData && !ticketDataError && allowedTicketTypes.length > 0 && ownedTickets.length > 0, [isAuthenticated, hasTicketData, ticketDataError, allowedTicketTypes, ownedTickets]); @@ -375,7 +391,6 @@ const RegistrationFormContent = ( return (
- updateClock(timestamp)} timezone={summitData.time_zone_id} /> {profileData && ticketDataError && handleGetTicketTypesAndTaxes(summitData?.id)} />} @@ -515,7 +530,6 @@ const RegistrationFormContent = ( goToMyOrders={goToMyOrders} goToExtraQuestions={goToExtraQuestions} completedExtraQuestions={completedExtraQuestions} - nowUtc={nowUtc} clearWidgetState={clearWidgetState} closeWidget={closeWidget} hasVirtualAccessLevel={hasVirtualAccessLevel} @@ -549,7 +563,6 @@ const mapStateToProps = ({ registrationLiteState }) => ({ passwordlessCodeLifeTime: registrationLiteState.passwordless.otp_lifetime, passwordlessCodeSent: registrationLiteState.passwordless.code_sent, passwordlessCodeError: registrationLiteState.passwordless.error, - nowUtc: registrationLiteState.nowUtc, promoCode: registrationLiteState.promoCode, promoCodeVerified: registrationLiteState.promoCodeVerified, promoCodeValidating: registrationLiteState.promoCodeValidating, @@ -569,7 +582,6 @@ const RegistrationForm = connect(mapStateToProps, { goToLogin, getMyInvitation, clearWidgetState, - updateClock, loadProfileData, applyPromoCode, removePromoCode, @@ -628,4 +640,4 @@ RegistrationForm.propTypes = { }; export { RegistrationForm }; -export default withReduxProvider(RegistrationForm); +export default withWidgetProviders(RegistrationForm); diff --git a/src/components/registration-modal/__tests__/registration-modal.test.js b/src/components/registration-modal/__tests__/registration-modal.test.js index bed1a89..d11b3ae 100644 --- a/src/components/registration-modal/__tests__/registration-modal.test.js +++ b/src/components/registration-modal/__tests__/registration-modal.test.js @@ -2,9 +2,9 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react'; import '@testing-library/jest-dom'; -// Mock withReduxProvider as identity HOC (modal default export wraps with it) -jest.mock('../../../utils/withReduxProvider', () => ({ - withReduxProvider: (Component) => Component, +// Mock withWidgetProviders as identity HOC (modal default export wraps with it) +jest.mock('../../../utils/withWidgetProviders', () => ({ + withWidgetProviders: (Component) => Component, __esModule: true, default: (Component) => Component, })); diff --git a/src/components/registration-modal/index.js b/src/components/registration-modal/index.js index ad48efb..4169443 100644 --- a/src/components/registration-modal/index.js +++ b/src/components/registration-modal/index.js @@ -16,7 +16,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { RegistrationForm } from '../registration-form'; -import { withReduxProvider } from '../../utils/withReduxProvider'; +import { withWidgetProviders } from '../../utils/withWidgetProviders'; import styles from "../../styles/general.module.scss"; const RegistrationModal = ({ summitData, closeWidget, ...props }) => { @@ -93,4 +93,4 @@ RegistrationModal.propTypes = { companyDDLOptions2Show: PropTypes.number, }; -export default withReduxProvider(RegistrationModal); +export default withWidgetProviders(RegistrationModal); diff --git a/src/reducer.js b/src/reducer.js index ae3f53f..b6549f5 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -31,7 +31,6 @@ import { CLEAR_MY_INVITATION, CLEAR_WIDGET_STATE, REQUESTED_TICKET_TYPES, - UPDATE_CLOCK, LOAD_PROFILE_DATA, SET_CURRENT_PROMO_CODE, CLEAR_CURRENT_PROMO_CODE, @@ -43,12 +42,8 @@ import { } from './actions'; import { LOGOUT_USER } from 'openstack-uicore-foundation/lib/security/actions'; -import moment from 'moment'; import { STEP_SELECT_TICKET_TYPE } from './utils/constants'; -const localNowUtc = moment().unix(); - - const DEFAULT_STATE = { reservation: null, checkout: null, @@ -72,7 +67,6 @@ const DEFAULT_STATE = { summitId: null, userProfile: null, }, - nowUtc: localNowUtc, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, @@ -169,10 +163,6 @@ const RegistrationLiteReducer = (state = DEFAULT_STATE, action) => { case CLEAR_MY_INVITATION: { return { ...state, invitation: null }; } - case UPDATE_CLOCK: { - const { timestamp } = payload; - return { ...state, nowUtc: timestamp }; - } case CLEAR_CURRENT_PROMO_CODE: { return { ...state, promoCode: '', promoCodeVerified: null, promoCodeValidating: false, promoCodeAllowsReassign: true } } diff --git a/src/utils/__tests__/withReduxProvider.test.js b/src/utils/__tests__/withWidgetProviders.test.js similarity index 78% rename from src/utils/__tests__/withReduxProvider.test.js rename to src/utils/__tests__/withWidgetProviders.test.js index 4d997b5..4775bdd 100644 --- a/src/utils/__tests__/withReduxProvider.test.js +++ b/src/utils/__tests__/withWidgetProviders.test.js @@ -3,8 +3,9 @@ import { cleanup, render } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Provider, connect } from 'react-redux'; import { createStore, combineReducers } from 'redux'; +import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; -import { withReduxProvider } from '../withReduxProvider'; +import { withWidgetProviders } from '../withWidgetProviders'; // Mock the store module const mockStore = { getState: jest.fn(), subscribe: jest.fn(), dispatch: jest.fn() }; @@ -31,7 +32,7 @@ const StubComponent = ({ clientId, apiBaseUrl, getAccessToken, ...rest }) => { - const Wrapped = withReduxProvider(StubComponent); + const Wrapped = withWidgetProviders(StubComponent); const { getByTestId } = render( 'token'} /> ); @@ -42,7 +43,7 @@ it('renders with Provider and creates a store via getStore/getPersistor', () => }); it('passes props through to the wrapped component', () => { - const Wrapped = withReduxProvider(StubComponent); + const Wrapped = withWidgetProviders(StubComponent); const { getByTestId } = render( 'token'} data-custom="hello" /> ); @@ -52,7 +53,7 @@ it('passes props through to the wrapped component', () => { }); it('caches store across re-renders (class constructor runs once)', () => { - const Wrapped = withReduxProvider(StubComponent); + const Wrapped = withWidgetProviders(StubComponent); const { rerender } = render( 'token'} /> ); @@ -68,13 +69,32 @@ it('caches store across re-renders (class constructor runs once)', () => { }); it('sets displayName based on wrapped component', () => { - const Wrapped = withReduxProvider(StubComponent); - expect(Wrapped.displayName).toBe('WithReduxProvider(StubComponent)'); + const Wrapped = withWidgetProviders(StubComponent); + expect(Wrapped.displayName).toBe('WithWidgetProviders(StubComponent)'); }); it('uses fallback displayName for anonymous component', () => { - const Wrapped = withReduxProvider(() =>
); - expect(Wrapped.displayName).toBe('WithReduxProvider(Component)'); + const Wrapped = withWidgetProviders(() =>
); + expect(Wrapped.displayName).toBe('WithWidgetProviders(Component)'); +}); + +it('provides ClockProvider so useClockSelector resolves against a live timestamp', () => { + const ClockConsumer = () => { + const year = useClockSelector((nowUtc) => + nowUtc ? new Date(nowUtc * 1000).getUTCFullYear() : null + ); + return
{year ?? 'null'}
; + }; + const Wrapped = withWidgetProviders(ClockConsumer); + const { getByTestId } = render( + 'token'} + summitData={{ time_zone_id: 'UTC' }} + /> + ); + expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024); }); describe('REGRESSION: widget works under a foreign Provider', () => { @@ -115,7 +135,7 @@ describe('REGRESSION: widget works under a foreign Provider', () => { // Override the mock so the HOC's getStore returns our real store mockGetStore.mockReturnValue(realStore); - const WrappedWithHOC = withReduxProvider(ConnectedComponent); + const WrappedWithHOC = withWidgetProviders(ConnectedComponent); // Create a FOREIGN store that does NOT have registrationLiteState const foreignStore = createStore( diff --git a/src/utils/withReduxProvider.js b/src/utils/withWidgetProviders.js similarity index 54% rename from src/utils/withReduxProvider.js rename to src/utils/withWidgetProviders.js index a191d29..f5a4a87 100644 --- a/src/utils/withReduxProvider.js +++ b/src/utils/withWidgetProviders.js @@ -10,38 +10,44 @@ * See the License for the specific language governing permissions and * limitations under the License. * - * HOC to wrap a component with Redux Provider and PersistGate. + * HOC to wrap a widget root with Redux, PersistGate, and ClockProvider. **/ import React from 'react'; import { Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; +import { ClockProvider } from "openstack-uicore-foundation/lib/components/clock-context"; import { getStore, getPersistor } from "../store"; -export const withReduxProvider = (WrappedComponent) => { - class WithReduxProvider extends React.PureComponent { +export const withWidgetProviders = (WrappedComponent) => { + class WithWidgetProviders extends React.PureComponent { constructor(props) { super(props); this.store = getStore(props.clientId, props.apiBaseUrl, props.getAccessToken); } render() { + const { summitData } = this.props; return ( - + + + ); } } - // Copy propTypes and defaultProps from wrapped component - WithReduxProvider.propTypes = WrappedComponent.propTypes; - WithReduxProvider.defaultProps = WrappedComponent.defaultProps; - WithReduxProvider.displayName = `WithReduxProvider(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + WithWidgetProviders.propTypes = WrappedComponent.propTypes; + WithWidgetProviders.defaultProps = WrappedComponent.defaultProps; + WithWidgetProviders.displayName = `WithWidgetProviders(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; - return WithReduxProvider; + return WithWidgetProviders; }; -export default withReduxProvider; +export default withWidgetProviders; diff --git a/yarn.lock b/yarn.lock index d42148f..f809a3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7512,10 +7512,12 @@ open@^8.0.9: is-docker "^2.1.1" is-wsl "^2.2.0" -openstack-uicore-foundation@4.2.14: - version "4.2.14" - resolved "https://registry.npmjs.org/openstack-uicore-foundation/-/openstack-uicore-foundation-4.2.14.tgz#d759a6358ede869f356fdf387065632b341bcf6a" - integrity sha512-ECwPZ7QOUrhW/fKbyWUfsYx/x207WPuLx9VfDLS2i46p3CoD+z6XT2a4AoUzYBoOa9iRdhXAtJUsymN8sGIzYA== +openstack-uicore-foundation@4.2.31: + version "4.2.31" + resolved "https://registry.yarnpkg.com/openstack-uicore-foundation/-/openstack-uicore-foundation-4.2.31.tgz#593b12ee1cd80cfa299f813b2470dcbde46cc580" + integrity sha512-DE44A0hr5mM9OCak8h4uEdNSjqBzeI+9yPf1/J+TgfzJPtZNrkW2+y43Z8jIHoByI6lBOCug31FuAo6ysDyaiw== + dependencies: + use-sync-external-store "^1.6.0" optimize-css-assets-webpack-plugin@^6.0.1: version "6.0.1" @@ -10171,6 +10173,11 @@ use-debounce@^6.0.1: resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-6.0.1.tgz#ed1eb2b30189408fb9792ea2887f4c6c3cb401a3" integrity sha512-kpvIxpa0vOLz/2I2sfNJ72mUeaT2CMNCu5BT1f2HkV9qZK27UVSOFf1sSSu+wjJE4TcR2VTXS2SM569+m3TN7Q== +use-sync-external-store@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 70c621d69e7b4c5dcf4d4636910f33235f3572aa Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 4 Jun 2026 18:21:17 -0300 Subject: [PATCH 2/6] chore(deps): add spark-md5 to satisfy openstack-uicore-foundation peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uicore 4.2.31's compiled output (notably company-input-v2) imports spark-md5, declared as a peer dep. CI's clean install can't resolve it, so webpack-dev-server emits "Module not found" and the widget renders the error overlay instead of the React tree — every e2e test failed looking for elements the React app never mounted. Locally the dep was hoisted from elsewhere so the issue stayed hidden until rebuilding node_modules from the lockfile. --- package.json | 2 ++ yarn.lock | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/package.json b/package.json index 5ff4e10..82b1ef9 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "regenerator-runtime": "^0.13.7", "sass": "^1.77.0", "sass-loader": "^12.6.0", + "spark-md5": "^3.0.2", "style-loader": "^3.3.1", "superagent": "^3.8.1", "sweetalert2": "^8.15.2", @@ -140,6 +141,7 @@ "redux": "^4.0.5", "redux-persist": "^5.9.1", "redux-thunk": "^2.3.0", + "spark-md5": "^3.0.2", "superagent": "^3.8.1", "sweetalert2": "^8.15.2", "urijs": "^1.19.1", diff --git a/yarn.lock b/yarn.lock index f809a3a..e82d732 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9460,6 +9460,11 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +spark-md5@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" + integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== + spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" From 21a84a0314a75b6d7e3a023fec9b6e5f469fcf05 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 4 Jun 2026 18:35:35 -0300 Subject: [PATCH 3/6] fix(clock): use Math.floor for unix seconds in ClockProvider seed Per review: Math.floor is the standard truncate-to-second; Math.round could nudge the seed up by ~0.5s on average. Functionally tiny but floor is the convention. --- src/utils/withWidgetProviders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/withWidgetProviders.js b/src/utils/withWidgetProviders.js index f5a4a87..5ed719b 100644 --- a/src/utils/withWidgetProviders.js +++ b/src/utils/withWidgetProviders.js @@ -33,7 +33,7 @@ export const withWidgetProviders = (WrappedComponent) => { From 80a143bb45461e4376aec4b233d7880182d3e6aa Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 4 Jun 2026 18:49:20 -0300 Subject: [PATCH 4/6] test(registration-form): exercise isTicketCurrentlyAvailable via render Per review: the 1970-shaped clock seed used everywhere else in the suite makes every realistic sales window evaluate to false, so a bug in the new isTicketCurrentlyAvailable helper would not surface here. Makes the clock seed and TicketTypeComponent's last props settable per test, then adds a regression that mixes in-window, expired, always-open and prepaid fixtures and asserts only the expected ones reach the rendered allowedTicketTypes prop. --- .../__tests__/registration-form.test.js | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/components/registration-form/__tests__/registration-form.test.js b/src/components/registration-form/__tests__/registration-form.test.js index 9a02b1e..c84a91e 100644 --- a/src/components/registration-form/__tests__/registration-form.test.js +++ b/src/components/registration-form/__tests__/registration-form.test.js @@ -12,9 +12,12 @@ jest.mock('../../../utils/withWidgetProviders', () => ({ default: (Component) => Component, })); -// Stub the uicore clock-context so the selector runs once against a fixed timestamp +// Stub the uicore clock-context so the selector runs once against a fixed +// timestamp. The seed is settable per test via `mockClockNow` (the `mock` +// prefix is the only identifier jest.mock allows the factory to capture). +let mockClockNow = 1000000; jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({ - useClockSelector: (selector) => selector(1000000), + useClockSelector: (selector) => selector(mockClockNow), ClockProvider: ({ children }) => children, })); @@ -127,7 +130,11 @@ jest.mock('openstack-uicore-foundation/lib/security/constants', () => ({ jest.mock('../../login', () => () =>
); jest.mock('../../payment', () => () =>
); jest.mock('../../personal-information', () => () =>
); -jest.mock('../../ticket-type', () => () =>
); +const mockTicketTypeProps = { current: null }; +jest.mock('../../ticket-type', () => (props) => { + mockTicketTypeProps.current = props; + return
; +}); jest.mock('../../button-bar', () => () =>
); jest.mock('../../purchase-complete', () => () =>
); jest.mock('../../login-passwordless', () => () =>
); @@ -236,6 +243,8 @@ const renderWithStore = (props = {}, stateOverrides = {}) => { afterEach(() => { cleanup(); jest.clearAllMocks(); + mockClockNow = 1000000; + mockTicketTypeProps.current = null; }); it('closeHandlerRef is assigned handleCloseClick via useEffect', () => { @@ -407,3 +416,51 @@ it('refires ticket-types fetch and promo discovery when summit id changes', asyn expect(mockDiscoverPromoCodes).toHaveBeenCalledTimes(1); expect(mockDiscoverPromoCodes).toHaveBeenCalledWith(2); }); + +// Exercises isTicketCurrentlyAvailable through the rendered prop, with the +// clock seed deliberately landing inside one ticket's sales window and outside +// the other's. Without this, a regression in the helper would slip through +// because the rest of the suite uses a 1970 seed against which every realistic +// window evaluates to false. +it('allowedTicketTypes contains the ticket whose sales window covers the clock seed', async () => { + mockClockNow = 1700000000; // 2023-11-14, well inside the in-window fixture + const inWindow = { + id: 101, + name: 'In Window', + sub_type: 'Regular', + sales_start_date: 1699000000, + sales_end_date: 1701000000, + }; + const expired = { + id: 102, + name: 'Expired', + sub_type: 'Regular', + sales_start_date: 1690000000, + sales_end_date: 1695000000, + }; + const alwaysOpen = { + id: 103, + name: 'Always Open', + sub_type: 'Regular', + sales_start_date: null, + sales_end_date: null, + }; + const prepaidExpired = { + id: 104, + name: 'Prepaid Past', + sub_type: 'PrePaid', + sales_start_date: 1690000000, + sales_end_date: 1695000000, + }; + + renderWithStore( + { ownedTickets: [] }, + { ticketTypes: [inWindow, expired, alwaysOpen, prepaidExpired] }, + ); + await act(async () => {}); + + expect(mockTicketTypeProps.current).not.toBeNull(); + const ids = mockTicketTypeProps.current.allowedTicketTypes.map((t) => t.id); + expect(ids).toEqual(expect.arrayContaining([101, 103, 104])); + expect(ids).not.toContain(102); +}); From c05158b6151a48d75e9b9d10be839258eab72973 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 4 Jun 2026 18:52:47 -0300 Subject: [PATCH 5/6] test(purchase-complete): cover both useClockSelector branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: PurchaseComplete had no test directory and the new useClockSelector-derived isActive boolean was unexercised. Adds one test per branch — seed inside summit.start_date/end_date asserts the active CTA path renders, seed outside asserts the "event will start on…" copy renders. --- .../__tests__/purchase-complete.test.js | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/components/purchase-complete/__tests__/purchase-complete.test.js diff --git a/src/components/purchase-complete/__tests__/purchase-complete.test.js b/src/components/purchase-complete/__tests__/purchase-complete.test.js new file mode 100644 index 0000000..e7e03ad --- /dev/null +++ b/src/components/purchase-complete/__tests__/purchase-complete.test.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { cleanup, render, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// Settable clock seed (the `mock` prefix is the only identifier jest.mock +// allows the factory to capture via closure). +let mockClockNow = 0; +jest.mock('openstack-uicore-foundation/lib/components/clock-context', () => ({ + useClockSelector: (selector) => selector(mockClockNow), +})); + +// epochToMomentTimeZone returns an object with .format(). Keep it cheap and +// deterministic so the test doesn't depend on moment / timezone data. +jest.mock('openstack-uicore-foundation/lib/utils/methods', () => ({ + epochToMomentTimeZone: jest.fn(() => ({ format: (fmt) => fmt === 'MMMM D' ? 'January 1' : '09:00 AM' })), +})); + +jest.mock('openstack-uicore-foundation/lib/components/raw-html', () => (props) =>
{props.children}
); + +import PurchaseComplete from '..'; + +const SUMMIT = { + start_date: 1_700_000_000, // ~Nov 2023 + end_date: 1_700_086_400, + time_zone_id: 'UTC', + time_zone_label: 'UTC', +}; + +const defaultProps = { + checkout: { tickets: [{ owner: { email: 'john@example.com' }, badge: {} }] }, + user: { email: 'john@example.com' }, + onPurchaseComplete: jest.fn(), + goToExtraQuestions: jest.fn(), + goToEvent: jest.fn(), + goToMyOrders: jest.fn(), + // The component waits on this promise before rendering the branch we want + // to assert, so resolve it synchronously to a deterministic value. + completedExtraQuestions: jest.fn(() => Promise.resolve(false)), + summit: SUMMIT, + clearWidgetState: jest.fn(), + closeWidget: jest.fn(), + supportEmail: 'help@example.com', + hasVirtualAccessLevel: false, +}; + +afterEach(() => { + cleanup(); + jest.clearAllMocks(); + mockClockNow = 0; +}); + +const renderAndFlush = async (props = {}) => { + let utils; + await act(async () => { + utils = render(); + }); + return utils; +}; + +it('renders the active CTA path when the clock seed falls inside the summit window', async () => { + mockClockNow = SUMMIT.start_date + 1000; + const { queryByText } = await renderAndFlush(); + + // The "event will start on…" copy belongs to the inactive branch. + expect(queryByText(/event will start on/i)).not.toBeInTheDocument(); + // The CTA in the active path with no required extra questions falls through + // to the My Orders/Tickets button. + expect(queryByText('View My Orders/Tickets')).toBeInTheDocument(); +}); + +it('renders the "event will start" copy when the clock seed is outside the summit window', async () => { + mockClockNow = SUMMIT.end_date + 1; // one second past end + const { queryByText } = await renderAndFlush(); + + expect(queryByText(/The event will start on January 1 at 09:00 AM UTC/)).toBeInTheDocument(); + // CTA still renders in the inactive branch (different layout). + expect(queryByText('View My Orders/Tickets')).toBeInTheDocument(); +}); From 94178c5081f17f7194e81d665b4f8d4433e6c972 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Thu, 4 Jun 2026 18:54:48 -0300 Subject: [PATCH 6/6] test(withWidgetProviders): cover the re-render path with a fresh timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the existing test only confirms the clock is live at initial mount. Adds a rerender with a different summitData.time_zone_id and asserts useClockSelector still resolves a valid year — exercising the PureComponent re-render that propagates a fresh timezone/now into ClockProvider, the path flagged as the risk in review. --- .../__tests__/withWidgetProviders.test.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/utils/__tests__/withWidgetProviders.test.js b/src/utils/__tests__/withWidgetProviders.test.js index 4775bdd..c70ff07 100644 --- a/src/utils/__tests__/withWidgetProviders.test.js +++ b/src/utils/__tests__/withWidgetProviders.test.js @@ -97,6 +97,38 @@ it('provides ClockProvider so useClockSelector resolves against a live timestamp expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024); }); +it('keeps useClockSelector live after summitData.time_zone_id changes', () => { + // PureComponent re-renders on prop change, which feeds a fresh `timezone` + // and `now` into ClockProvider. The consumer must still resolve to a + // valid timestamp after that propagation. + const ClockConsumer = () => { + const year = useClockSelector((nowUtc) => + nowUtc ? new Date(nowUtc * 1000).getUTCFullYear() : null + ); + return
{year ?? 'null'}
; + }; + const Wrapped = withWidgetProviders(ClockConsumer); + const { getByTestId, rerender } = render( + 'token'} + summitData={{ time_zone_id: 'UTC' }} + /> + ); + expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024); + + rerender( + 'token'} + summitData={{ time_zone_id: 'America/New_York' }} + /> + ); + expect(Number(getByTestId('year').textContent)).toBeGreaterThanOrEqual(2024); +}); + describe('REGRESSION: widget works under a foreign Provider', () => { // This test proves the HOC always creates its own Provider, regardless of // any outer Provider. A connect()-based component that reads from