From cff5fd6a8be3e22193aaa710db5b0dafbb38d164 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Tue, 17 Feb 2026 14:39:53 +0000 Subject: [PATCH 01/10] WIP - clearer messagePicker result --- .../StickyBottomBanner.importable.tsx | 27 ++++++++++--------- dotcom-rendering/src/lib/messagePicker.ts | 21 +++++++++++---- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index f82bb658b54..a02ce3fbf23 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -11,7 +11,7 @@ import type { ArticleCounts } from '../lib/articleCount'; import { getArticleCounts } from '../lib/articleCount'; import type { CandidateConfig, - MaybeFC, + PickMessageResult, SlotConfig, } from '../lib/messagePicker'; import { pickMessage } from '../lib/messagePicker'; @@ -274,9 +274,9 @@ export const StickyBottomBanner = ({ 'control', ); - const [SelectedBanner, setSelectedBanner] = useState(null); - const [hasPickMessageCompleted, setHasPickMessageCompleted] = - useState(false); + const [pickMessageResult, setPickMessageResult] = + useState(null); + const [asyncArticleCounts, setAsyncArticleCounts] = useState>(); @@ -367,9 +367,9 @@ export const StickyBottomBanner = ({ }; pickMessage(bannerConfig, renderingTarget) - .then((PickedBanner: () => MaybeFC) => { - setSelectedBanner(PickedBanner); - setHasPickMessageCompleted(true); + .then((result) => { + setPickMessageResult(result); + // setHasPickMessageCompleted(true); }) .catch((e) => { // Report error to Sentry @@ -380,7 +380,7 @@ export const StickyBottomBanner = ({ new Error(msg), 'sticky-bottom-banner', ); - setHasPickMessageCompleted(true); + // setHasPickMessageCompleted(true); }); }, [ isSignedIn, @@ -410,12 +410,15 @@ export const StickyBottomBanner = ({ // Ensures ads only insert when no banner will be shown. // hasPickMessageCompleted distinguishes between initial state (not picked yet) and final state (picked nothing). useEffect(() => { - if (hasPickMessageCompleted && SelectedBanner == null) { + if (pickMessageResult?.type === 'NoMessageSelected') { document.dispatchEvent(new CustomEvent('banner:none')); } - }, [SelectedBanner, hasPickMessageCompleted]); - if (SelectedBanner) { - return ; + }, [pickMessageResult]); + if (pickMessageResult?.type === 'MessageSelected') { + const { SelectedMessage } = pickMessageResult; + if (SelectedMessage) { + return ; + } } return null; diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index f07ff1fd4a5..6032f2e13fe 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -114,8 +114,6 @@ const timeoutify = ( const clearAllTimeouts = (messages: CandidateConfigWithTimeout[]) => messages.map((m) => m.cancelTimeout()); -const defaultShow = () => null; - interface PendingMessage { candidateConfig: CandidateConfigWithTimeout; canShow: Promise>; @@ -126,10 +124,20 @@ interface WinningMessage { candidate: Candidate; } +interface NoMessageSelected { + type: 'NoMessageSelected'; +} +interface MessageSelected { + type: 'MessageSelected'; + // The react component is optional because sometimes we selected a message for this slot, but there is nothing to render + SelectedMessage: MaybeFC; +} +export type PickMessageResult = NoMessageSelected | MessageSelected; + export const pickMessage = ( { candidates, name }: SlotConfig, renderingTarget: RenderingTarget, -): Promise<() => MaybeFC> => +): Promise => new Promise((resolve) => { const candidateConfigsWithTimeout = candidates.map((c) => timeoutify(c, name, renderingTarget), @@ -165,10 +173,13 @@ export const pickMessage = ( clearAllTimeouts(candidateConfigsWithTimeout); if (winner === null) { - resolve(defaultShow); + resolve({ type: 'NoMessageSelected' }); } else { const { candidate, meta } = winner; - resolve(() => candidate.show(meta)); + resolve({ + type: 'MessageSelected', + SelectedMessage: candidate.show(meta), + }); } }) .catch((e) => From d2e75f72867a9e612971f1ffdfff2955463de0a7 Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Tue, 17 Feb 2026 14:43:28 +0000 Subject: [PATCH 02/10] fix tests --- .../src/lib/messagePicker.test.tsx | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/dotcom-rendering/src/lib/messagePicker.test.tsx b/dotcom-rendering/src/lib/messagePicker.test.tsx index 1a2b2d74fb3..ef997005989 100644 --- a/dotcom-rendering/src/lib/messagePicker.test.tsx +++ b/dotcom-rendering/src/lib/messagePicker.test.tsx @@ -56,9 +56,12 @@ describe('pickMessage', () => { ], }; - const got = await pickMessage(config, 'Web'); + const result = await pickMessage(config, 'Web'); - expect(got()).toEqual(ChosenMockComponent); + expect(result.type).toEqual('MessageSelected'); + if (result.type === 'MessageSelected') { + expect(result.SelectedMessage).toEqual(ChosenMockComponent); + } }); it('resolves with null if no messages can show', async () => { @@ -86,9 +89,9 @@ describe('pickMessage', () => { ], }; - const got = await pickMessage(config, 'Web'); + const result = await pickMessage(config, 'Web'); - expect(got()).toEqual(null); + expect(result.type).toEqual('NoMessageSelected'); }); it('falls through to a lower priority message when a higher one times out', async () => { @@ -129,9 +132,12 @@ describe('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(260); - const got = await messagePromise; + const result = await messagePromise; - expect(got()).toEqual(ChosenMockComponent); + expect(result.type).toEqual('MessageSelected'); + if (result.type === 'MessageSelected') { + expect(result.SelectedMessage).toEqual(ChosenMockComponent); + } }); it('resolves with null if all messages time out', async () => { @@ -182,9 +188,9 @@ describe('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(260); - const got = await messagePromise; + const result = await messagePromise; - expect(got()).toEqual(null); + expect(result.type).toEqual('NoMessageSelected'); clearTimeout(timer1); clearTimeout(timer2); @@ -211,8 +217,7 @@ describe('pickMessage', () => { ], }; - const show = await pickMessage(config, 'Web'); - show(); + await pickMessage(config, 'Web'); expect(renderComponent).toHaveBeenCalledWith(meta); }); @@ -246,9 +251,9 @@ describe('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(250); - const got = await messagePromise; + const result = await messagePromise; - expect(got()).toEqual(null); + expect(result.type).toEqual('NoMessageSelected'); expect(ophanRecordSpy).toHaveBeenCalledWith({ component: 'banner-picker-timeout-dcr', From 90991ecea835691721dffa37e69c473366aa12ab Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Tue, 17 Feb 2026 14:45:45 +0000 Subject: [PATCH 03/10] fix epic --- .../src/components/SlotBodyEnd.importable.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx b/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx index 7b8b476863f..241f3f0729e 100644 --- a/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx +++ b/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx @@ -14,11 +14,7 @@ import type { import type { EpicProps } from '@guardian/support-dotcom-components/dist/shared/types'; import { useEffect, useState } from 'react'; import { getArticleCounts } from '../lib/articleCount'; -import type { - CandidateConfig, - MaybeFC, - SlotConfig, -} from '../lib/messagePicker'; +import type { CandidateConfig, SlotConfig } from '../lib/messagePicker'; import { pickMessage } from '../lib/messagePicker'; import { useIsSignedIn } from '../lib/useAuthStatus'; import { useBraze } from '../lib/useBraze'; @@ -183,7 +179,13 @@ export const SlotBodyEnd = ({ name: 'slotBodyEnd', }; pickMessage(epicConfig, renderingTarget) - .then((PickedEpic: () => MaybeFC) => setSelectedEpic(PickedEpic)) + .then((result) => { + if (result.type === 'MessageSelected') { + setSelectedEpic(() => result.SelectedMessage); + } else { + setSelectedEpic(() => null); + } + }) .catch((e) => console.error(`SlotBodyEnd pickMessage - error: ${String(e)}`), ); From 084b5315cb1e8fc34edce625ec58813a83b46a1e Mon Sep 17 00:00:00 2001 From: Charlotte Date: Tue, 17 Feb 2026 16:32:52 +0000 Subject: [PATCH 04/10] tidy up + add logging to view picked message in browser --- .../StickyBottomBanner.importable.tsx | 8 ++----- dotcom-rendering/src/lib/messagePicker.ts | 22 ++++++++++++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index a02ce3fbf23..fe2dc1af962 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -369,7 +369,6 @@ export const StickyBottomBanner = ({ pickMessage(bannerConfig, renderingTarget) .then((result) => { setPickMessageResult(result); - // setHasPickMessageCompleted(true); }) .catch((e) => { // Report error to Sentry @@ -380,7 +379,6 @@ export const StickyBottomBanner = ({ new Error(msg), 'sticky-bottom-banner', ); - // setHasPickMessageCompleted(true); }); }, [ isSignedIn, @@ -408,17 +406,15 @@ export const StickyBottomBanner = ({ // Dispatches 'banner:none' event for mobile sticky ad integration (see @guardian/commercial-dev). // Ensures ads only insert when no banner will be shown. - // hasPickMessageCompleted distinguishes between initial state (not picked yet) and final state (picked nothing). useEffect(() => { if (pickMessageResult?.type === 'NoMessageSelected') { document.dispatchEvent(new CustomEvent('banner:none')); } }, [pickMessageResult]); + if (pickMessageResult?.type === 'MessageSelected') { const { SelectedMessage } = pickMessageResult; - if (SelectedMessage) { - return ; - } + return SelectedMessage(); } return null; diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index 6032f2e13fe..45c036648ed 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -1,4 +1,4 @@ -import { isUndefined, startPerformanceMeasure } from '@guardian/libs'; +import { isUndefined, log, startPerformanceMeasure } from '@guardian/libs'; import { getOphan } from '../client/ophan/ophan'; import type { RenderingTarget } from '../types/renderingTarget'; @@ -130,7 +130,7 @@ interface NoMessageSelected { interface MessageSelected { type: 'MessageSelected'; // The react component is optional because sometimes we selected a message for this slot, but there is nothing to render - SelectedMessage: MaybeFC; + SelectedMessage: () => MaybeFC; } export type PickMessageResult = NoMessageSelected | MessageSelected; @@ -139,6 +139,11 @@ export const pickMessage = ( renderingTarget: RenderingTarget, ): Promise => new Promise((resolve) => { + // Temporary variable to filter messages from the StickyBottomBanner only + const allowConsoleLog = candidates.some( + (c) => c.candidate.id === 'cmpUi', + ); + const candidateConfigsWithTimeout = candidates.map((c) => timeoutify(c, name, renderingTarget), ); @@ -149,6 +154,14 @@ export const pickMessage = ( }), ); + if (allowConsoleLog) { + log( + 'commercial', + '☑️ [pick] possible messages:', + results.map((_) => _.candidateConfig.candidate.id), + ); + } + const winnerResult = results.reduce< Promise | null> >(async (winningMessageSoFar, { candidateConfig, canShow }) => { @@ -170,6 +183,9 @@ export const pickMessage = ( winnerResult .then((winner) => { + if (allowConsoleLog) { + log('commercial', '☑️ [pick] picked message:', winner); + } clearAllTimeouts(candidateConfigsWithTimeout); if (winner === null) { @@ -178,7 +194,7 @@ export const pickMessage = ( const { candidate, meta } = winner; resolve({ type: 'MessageSelected', - SelectedMessage: candidate.show(meta), + SelectedMessage: () => candidate.show(meta), }); } }) From 89b4eaa22d207d7ac46f02810aaf13fe9bd104ed Mon Sep 17 00:00:00 2001 From: Charlotte Date: Tue, 17 Feb 2026 17:00:34 +0000 Subject: [PATCH 05/10] fix types --- .../src/components/SlotBodyEnd.importable.tsx | 6 ++--- .../StickyBottomBanner.importable.tsx | 12 ++++----- .../src/lib/messagePicker.test.tsx | 26 +++++++++---------- dotcom-rendering/src/lib/messagePicker.ts | 2 +- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx b/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx index 241f3f0729e..3547e6246bf 100644 --- a/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx +++ b/dotcom-rendering/src/components/SlotBodyEnd.importable.tsx @@ -56,7 +56,7 @@ const buildReaderRevenueEpicConfig = ( candidate: { id: 'reader-revenue-banner', canShow: () => canShowReaderRevenueEpic(canShowData), - show: (data: ModuleData) => () => { + show: (data: ModuleData) => { return ; }, }, @@ -84,7 +84,7 @@ const buildBrazeEpicConfig = ( tags, shouldHideReaderRevenue, ), - show: (meta: any) => () => ( + show: (meta: any) => ( { if (result.type === 'MessageSelected') { - setSelectedEpic(() => result.SelectedMessage); + setSelectedEpic(result.SelectedMessage); } else { setSelectedEpic(() => null); } diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index fe2dc1af962..db031aa0758 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -170,9 +170,9 @@ const buildRRBannerConfigWith = ({ ophanPageViewId, pageId, }), - show: - ({ name, props }: ModuleData) => - () => , + show: ({ name, props }: ModuleData) => ( + + ), }, timeoutMillis: DEFAULT_BANNER_TIMEOUT_MILLIS, }; @@ -188,7 +188,7 @@ const buildSignInGateConfig = ( canShow: async () => { return await canShowSignInGatePortal(canShowProps); }, - show: (meta: AuxiaGateDisplayData) => () => ( + show: (meta: AuxiaGateDisplayData) => ( () => ( + show: (meta: BrazeMeta) => ( ), }, @@ -414,7 +414,7 @@ export const StickyBottomBanner = ({ if (pickMessageResult?.type === 'MessageSelected') { const { SelectedMessage } = pickMessageResult; - return SelectedMessage(); + return ; } return null; diff --git a/dotcom-rendering/src/lib/messagePicker.test.tsx b/dotcom-rendering/src/lib/messagePicker.test.tsx index ef997005989..b8fa7ca2d4d 100644 --- a/dotcom-rendering/src/lib/messagePicker.test.tsx +++ b/dotcom-rendering/src/lib/messagePicker.test.tsx @@ -31,7 +31,7 @@ describe('pickMessage', () => { candidate: { id: 'banner-1', canShow: () => Promise.resolve({ show: false }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, }, @@ -40,7 +40,7 @@ describe('pickMessage', () => { id: 'banner-2', canShow: () => Promise.resolve({ show: true, meta: undefined }), - show: () => ChosenMockComponent, + show: ChosenMockComponent, }, timeoutMillis: null, }, @@ -49,7 +49,7 @@ describe('pickMessage', () => { id: 'banner-3', canShow: () => Promise.resolve({ show: true, meta: undefined }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, }, @@ -74,7 +74,7 @@ describe('pickMessage', () => { candidate: { id: 'banner-1', canShow: () => Promise.resolve({ show: false }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, }, @@ -82,7 +82,7 @@ describe('pickMessage', () => { candidate: { id: 'banner-2', canShow: () => Promise.resolve({ show: false }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, }, @@ -114,7 +114,7 @@ describe('pickMessage', () => { 500, ), ), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: 250, }, @@ -123,7 +123,7 @@ describe('pickMessage', () => { id: 'banner-2', canShow: () => Promise.resolve({ show: true, meta: undefined }), - show: () => ChosenMockComponent, + show: ChosenMockComponent, }, timeoutMillis: null, }, @@ -161,7 +161,7 @@ describe('pickMessage', () => { 500, ); }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: 250, }, @@ -179,7 +179,7 @@ describe('pickMessage', () => { 500, ); }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: 250, }, @@ -197,7 +197,7 @@ describe('pickMessage', () => { }); it('passes metadata returned by canShow to show', async () => { - const renderComponent = jest.fn(() => () =>
); + const renderComponent = jest.fn(() =>
); const meta = { extra: 'info' }; const config: SlotConfig = { name: 'banner', @@ -242,7 +242,7 @@ describe('pickMessage', () => { 300, ); }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: 200, }, @@ -283,7 +283,7 @@ describe('pickMessage', () => { 120, ); }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, reportTiming: true, @@ -302,7 +302,7 @@ describe('pickMessage', () => { 100, ); }), - show: () => MockComponent, + show: MockComponent, }, timeoutMillis: null, }, diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index 45c036648ed..1cb58ece3ef 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -2,7 +2,7 @@ import { isUndefined, log, startPerformanceMeasure } from '@guardian/libs'; import { getOphan } from '../client/ophan/ophan'; import type { RenderingTarget } from '../types/renderingTarget'; -export type MaybeFC = React.FC | null; +export type MaybeFC = React.ReactNode | null; type ShowMessage = (meta: T) => MaybeFC; interface ShouldShow { From df188e3aaf31996b6ee1b8558a29877cad4487a5 Mon Sep 17 00:00:00 2001 From: Charlotte Date: Tue, 17 Feb 2026 17:06:10 +0000 Subject: [PATCH 06/10] temporarily skip failing tests --- dotcom-rendering/src/lib/messagePicker.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/lib/messagePicker.test.tsx b/dotcom-rendering/src/lib/messagePicker.test.tsx index b8fa7ca2d4d..36fab066ef5 100644 --- a/dotcom-rendering/src/lib/messagePicker.test.tsx +++ b/dotcom-rendering/src/lib/messagePicker.test.tsx @@ -20,7 +20,7 @@ afterEach(async () => { jest.clearAllMocks(); }); -describe('pickMessage', () => { +describe.skip('pickMessage', () => { it('resolves with the highest priority message which can show', async () => { const MockComponent = () =>
; const ChosenMockComponent = () =>
; @@ -136,7 +136,7 @@ describe('pickMessage', () => { expect(result.type).toEqual('MessageSelected'); if (result.type === 'MessageSelected') { - expect(result.SelectedMessage).toEqual(ChosenMockComponent); + expect(result.SelectedMessage).toEqual(() => ChosenMockComponent); } }); @@ -196,7 +196,7 @@ describe('pickMessage', () => { clearTimeout(timer2); }); - it('passes metadata returned by canShow to show', async () => { + it.skip('passes metadata returned by canShow to show', async () => { const renderComponent = jest.fn(() =>
); const meta = { extra: 'info' }; const config: SlotConfig = { From d3bb64b3ae5ec96dbe802bdfe62f4f2e3503b838 Mon Sep 17 00:00:00 2001 From: Charlotte Date: Wed, 18 Feb 2026 10:55:36 +0000 Subject: [PATCH 07/10] add additional metadata to PickMessageResult so we can fire banner:none for the sign in gate --- .../src/components/StickyBottomBanner.importable.tsx | 10 +++++++--- dotcom-rendering/src/lib/messagePicker.ts | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index db031aa0758..cf81866256e 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -404,10 +404,14 @@ export const StickyBottomBanner = ({ isInAuxiaControlGroup, ]); - // Dispatches 'banner:none' event for mobile sticky ad integration (see @guardian/commercial-dev). - // Ensures ads only insert when no banner will be shown. + // Dispatches 'banner:none' event for commercial to handle for the mobile sticky ad integration (@guardian/commercial-dev) + // Ensures the mobile-sticky ad slots only render when no banner will be shown. useEffect(() => { - if (pickMessageResult?.type === 'NoMessageSelected') { + if ( + pickMessageResult?.type === 'NoMessageSelected' || + // Explicitly ignore the sign in gate for this + pickMessageResult?.messageId === 'sign-in-gate-portal' + ) { document.dispatchEvent(new CustomEvent('banner:none')); } }, [pickMessageResult]); diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index 1cb58ece3ef..d47fd3d65a6 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -129,6 +129,7 @@ interface NoMessageSelected { } interface MessageSelected { type: 'MessageSelected'; + messageId: string; // The react component is optional because sometimes we selected a message for this slot, but there is nothing to render SelectedMessage: () => MaybeFC; } @@ -194,6 +195,7 @@ export const pickMessage = ( const { candidate, meta } = winner; resolve({ type: 'MessageSelected', + messageId: candidate.id, SelectedMessage: () => candidate.show(meta), }); } From f52caa6fa6f05bf315850d89b39f8163e2247a4f Mon Sep 17 00:00:00 2001 From: Charlotte Date: Thu, 19 Feb 2026 10:57:16 +0000 Subject: [PATCH 08/10] fire separate event for the sign in gate + update comment explaining the purpose of the events --- .../StickyBottomBanner.importable.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx index cf81866256e..e330d3c0a57 100644 --- a/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx +++ b/dotcom-rendering/src/components/StickyBottomBanner.importable.tsx @@ -404,15 +404,21 @@ export const StickyBottomBanner = ({ isInAuxiaControlGroup, ]); - // Dispatches 'banner:none' event for commercial to handle for the mobile sticky ad integration (@guardian/commercial-dev) - // Ensures the mobile-sticky ad slots only render when no banner will be shown. + /** + * Custom events for commercial purposes to avoid the mobile-sticky ad slot clashing with these banners + * Dispatches events when no banner is due to appear and when the sign in gate is the chosen banner + * Please talk to @guardian/commercial-dev before changing this logic + */ useEffect(() => { + if (pickMessageResult?.type === 'NoMessageSelected') { + document.dispatchEvent(new CustomEvent('banner:none')); + } + if ( - pickMessageResult?.type === 'NoMessageSelected' || - // Explicitly ignore the sign in gate for this - pickMessageResult?.messageId === 'sign-in-gate-portal' + pickMessageResult?.type === 'MessageSelected' && + pickMessageResult.messageId === 'sign-in-gate-portal' ) { - document.dispatchEvent(new CustomEvent('banner:none')); + document.dispatchEvent(new CustomEvent('banner:sign-in-gate')); } }, [pickMessageResult]); From 5a059da2514868ab58d8a696413a1d2ce5d90aad Mon Sep 17 00:00:00 2001 From: Charlotte Date: Thu, 19 Feb 2026 10:57:50 +0000 Subject: [PATCH 09/10] update tests for messagePicker & remove console logs from the function itself --- .../src/lib/messagePicker.test.tsx | 47 +++++++++++-------- dotcom-rendering/src/lib/messagePicker.ts | 18 +------ 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/dotcom-rendering/src/lib/messagePicker.test.tsx b/dotcom-rendering/src/lib/messagePicker.test.tsx index 36fab066ef5..d3128ae8efd 100644 --- a/dotcom-rendering/src/lib/messagePicker.test.tsx +++ b/dotcom-rendering/src/lib/messagePicker.test.tsx @@ -20,10 +20,10 @@ afterEach(async () => { jest.clearAllMocks(); }); -describe.skip('pickMessage', () => { +describe('pickMessage', () => { it('resolves with the highest priority message which can show', async () => { const MockComponent = () =>
; - const ChosenMockComponent = () =>
; + const ChosenMockComponent = () =>
; const config: SlotConfig = { name: 'banner', candidates: [ @@ -56,11 +56,12 @@ describe.skip('pickMessage', () => { ], }; - const result = await pickMessage(config, 'Web'); - - expect(result.type).toEqual('MessageSelected'); - if (result.type === 'MessageSelected') { - expect(result.SelectedMessage).toEqual(ChosenMockComponent); + const pickMessageResult = await pickMessage(config, 'Web'); + expect(pickMessageResult.type).toEqual('MessageSelected'); + if (pickMessageResult.type === 'MessageSelected') { + expect(pickMessageResult.SelectedMessage()).toEqual( + ChosenMockComponent(), + ); } }); @@ -89,14 +90,14 @@ describe.skip('pickMessage', () => { ], }; - const result = await pickMessage(config, 'Web'); + const pickMessageResult = await pickMessage(config, 'Web'); - expect(result.type).toEqual('NoMessageSelected'); + expect(pickMessageResult.type).toEqual('NoMessageSelected'); }); it('falls through to a lower priority message when a higher one times out', async () => { const MockComponent = () =>
; - const ChosenMockComponent = () =>
; + const ChosenMockComponent = () =>
; const config: SlotConfig = { name: 'banner', candidates: [ @@ -132,11 +133,13 @@ describe.skip('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(260); - const result = await messagePromise; - expect(result.type).toEqual('MessageSelected'); - if (result.type === 'MessageSelected') { - expect(result.SelectedMessage).toEqual(() => ChosenMockComponent); + const pickMessageResult = await messagePromise; + expect(pickMessageResult.type).toEqual('MessageSelected'); + if (pickMessageResult.type === 'MessageSelected') { + expect(pickMessageResult.SelectedMessage()).toEqual( + ChosenMockComponent(), + ); } }); @@ -188,15 +191,15 @@ describe.skip('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(260); - const result = await messagePromise; + const got = await messagePromise; - expect(result.type).toEqual('NoMessageSelected'); + expect(got.type).toEqual('NoMessageSelected'); clearTimeout(timer1); clearTimeout(timer2); }); - it.skip('passes metadata returned by canShow to show', async () => { + it('passes metadata returned by canShow to show', async () => { const renderComponent = jest.fn(() =>
); const meta = { extra: 'info' }; const config: SlotConfig = { @@ -217,7 +220,11 @@ describe.skip('pickMessage', () => { ], }; - await pickMessage(config, 'Web'); + const show = await pickMessage(config, 'Web'); + expect(show.type).toEqual('MessageSelected'); + if (show.type === 'MessageSelected') { + expect(show.SelectedMessage()).toEqual(renderComponent()); + } expect(renderComponent).toHaveBeenCalledWith(meta); }); @@ -251,9 +258,9 @@ describe.skip('pickMessage', () => { const messagePromise = pickMessage(config, 'Web'); jest.advanceTimersByTime(250); - const result = await messagePromise; + const got = await messagePromise; - expect(result.type).toEqual('NoMessageSelected'); + expect(got.type).toEqual('NoMessageSelected'); expect(ophanRecordSpy).toHaveBeenCalledWith({ component: 'banner-picker-timeout-dcr', diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index d47fd3d65a6..d0cb0f66e8e 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -1,4 +1,4 @@ -import { isUndefined, log, startPerformanceMeasure } from '@guardian/libs'; +import { isUndefined, startPerformanceMeasure } from '@guardian/libs'; import { getOphan } from '../client/ophan/ophan'; import type { RenderingTarget } from '../types/renderingTarget'; @@ -140,11 +140,6 @@ export const pickMessage = ( renderingTarget: RenderingTarget, ): Promise => new Promise((resolve) => { - // Temporary variable to filter messages from the StickyBottomBanner only - const allowConsoleLog = candidates.some( - (c) => c.candidate.id === 'cmpUi', - ); - const candidateConfigsWithTimeout = candidates.map((c) => timeoutify(c, name, renderingTarget), ); @@ -155,14 +150,6 @@ export const pickMessage = ( }), ); - if (allowConsoleLog) { - log( - 'commercial', - '☑️ [pick] possible messages:', - results.map((_) => _.candidateConfig.candidate.id), - ); - } - const winnerResult = results.reduce< Promise | null> >(async (winningMessageSoFar, { candidateConfig, canShow }) => { @@ -184,9 +171,6 @@ export const pickMessage = ( winnerResult .then((winner) => { - if (allowConsoleLog) { - log('commercial', '☑️ [pick] picked message:', winner); - } clearAllTimeouts(candidateConfigsWithTimeout); if (winner === null) { From 4f5b364675c6c9c9747d92f1c864e13e7cc97b53 Mon Sep 17 00:00:00 2001 From: Charlotte Date: Thu, 19 Feb 2026 11:31:31 +0000 Subject: [PATCH 10/10] return NoMessageSelected if errors in result handling --- dotcom-rendering/src/lib/messagePicker.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/lib/messagePicker.ts b/dotcom-rendering/src/lib/messagePicker.ts index d0cb0f66e8e..10b7ea9bfaa 100644 --- a/dotcom-rendering/src/lib/messagePicker.ts +++ b/dotcom-rendering/src/lib/messagePicker.ts @@ -184,7 +184,8 @@ export const pickMessage = ( }); } }) - .catch((e) => - console.error(`pickMessage winner - error: ${String(e)}`), - ); + .catch((e) => { + console.error(`pickMessage winner - error: ${String(e)}`); + resolve({ type: 'NoMessageSelected' }); + }); });