From 6a1aab67bfc5a0e71d5ac43caf5705c5c1fa3be1 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 17 Jun 2026 15:15:45 +0100 Subject: [PATCH 01/29] Add Braze banner integration to Feast contextual nudge component --- .../FeastContextualNudge.island.tsx | 36 ++++++++++++++++++- dotcom-rendering/src/lib/ArticleRenderer.tsx | 18 +++++++++- .../src/lib/braze/BrazeBannersSystem.tsx | 5 +++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index db65578bac4..aca061f94c2 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -7,7 +7,12 @@ import { } from '@guardian/source/foundations'; import { LinkButton } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; +import { + BrazeBannersSystemDisplay, + BrazeBannersSystemPlacementId, +} from '../lib/braze/BrazeBannersSystem'; import { useAB } from '../lib/useAB'; +import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; import type { RecipeBlockElement } from '../types/content'; import { useConfig } from './ConfigContext'; @@ -122,6 +127,8 @@ type FeastContextualNudgeProps = { recipeArticleTitle: string; pageId: string; isDev: boolean; + nudgeIndex: number; + idApiUrl: string | undefined; }; /** @@ -142,12 +149,16 @@ export const FeastContextualNudge = ({ recipeArticleTitle, pageId, isDev, + nudgeIndex, + idApiUrl, }: FeastContextualNudgeProps) => { const abTests = useAB(); const isVariant = abTests?.isUserInTestGroup('feast-recipe-nudge', 'variant-1') ?? false; - const { darkModeAvailable } = useConfig(); + const { darkModeAvailable, renderingTarget } = useConfig(); + + const { braze } = useBraze(idApiUrl ?? '', renderingTarget); const [isStorybook, setIsStorybook] = useState(false); useEffect(() => { @@ -173,6 +184,29 @@ export const FeastContextualNudge = ({ if (!isVariant) return null; + // If idApiUrl is defined and Braze has a banner for this placement slot, + // render the Braze banner instead of the native nudge. + if (idApiUrl !== undefined) { + const placementId = + BrazeBannersSystemPlacementId[ + `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId + ]; + const banner = braze?.getBanner(placementId) ?? null; + if (banner && braze) { + return ( + + ); + } + } + return (
{section.subheadingEl} - {section.recipe && ( + {section.recipe && nudgeIndex !== null && ( )} diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index ffc732ebe0e..d4f59eaf707 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -106,6 +106,11 @@ export const brazeBannersSystemLogger = { export enum BrazeBannersSystemPlacementId { EndOfArticle = 'dotcom-rendering_end-of-article', Banner = 'dotcom-rendering_banner', + FeastContextualNudge1 = 'dotcom-rendering_feast-contextual-nudge-1', + FeastContextualNudge2 = 'dotcom-rendering_feast-contextual-nudge-2', + FeastContextualNudge3 = 'dotcom-rendering_feast-contextual-nudge-3', + FeastContextualNudge4 = 'dotcom-rendering_feast-contextual-nudge-4', + FeastContextualNudge5 = 'dotcom-rendering_feast-contextual-nudge-5', } /** From 79f34e598728a4a88c9c4febda41699eb67d53ee Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 17 Jun 2026 15:20:27 +0100 Subject: [PATCH 02/29] Add nudgeIndex and idApiUrl to FeastContextualNudge story args --- .../src/components/FeastContextualNudge.stories.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx index 89eff8100fb..4a86deb5d9d 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx @@ -43,6 +43,8 @@ const meta = { recipeArticleTitle: "Meera Sodha's spring onion pancakes", recipe: mockRecipe, isDev: true, + nudgeIndex: 1, + idApiUrl: undefined, }, parameters: { chromatic: { viewports: [375, 740, 980] }, From 2bcaf2f173ace7168a185da19a43e57c3cc8a7c1 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 11:18:09 +0100 Subject: [PATCH 03/29] Add GetContext message type and context parameter to Braze Banners System --- .../src/lib/braze/BrazeBannersSystem.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index d4f59eaf707..cb7652a5133 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -318,6 +318,7 @@ enum BrazeBannersSystemMessageType { GetSettingsPropertyValue = 'BRAZE_BANNERS_SYSTEM:GET_SETTINGS_PROPERTY_VALUE', NavigateToUrl = 'BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL', DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER', + GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT', } /** @@ -452,16 +453,20 @@ const runCssCheckerOnBrazeBanner = ( * Displays a Braze Banner using the Braze Banners System. * @param meta Meta information required to display the banner * @param idApiUrl Identity API URL for newsletter subscriptions + * @param stage Current stage of the application (e.g., PROD, CODE) + * @param context Additional context for the banner (optional) * @returns React component that renders the Braze Banner */ export const BrazeBannersSystemDisplay = ({ meta, idApiUrl, stage, + context, }: { meta: BrazeBannersSystemMeta; idApiUrl: string; stage: StageType; + context?: unknown; }) => { const supportOrigin = isProd(stage) ? 'https://support.theguardian.com' @@ -750,6 +755,9 @@ export const BrazeBannersSystemDisplay = ({ | { type: BrazeBannersSystemMessageType.DismissBanner; } + | { + type: BrazeBannersSystemMessageType.GetContext; + } >, ) => { if ( @@ -891,6 +899,14 @@ export const BrazeBannersSystemDisplay = ({ case BrazeBannersSystemMessageType.DismissBanner: dismissBanner(); break; + case BrazeBannersSystemMessageType.GetContext: + postMessageToBrazeBanner( + BrazeBannersSystemMessageType.GetContext, + { + context, + }, + ); + break; } }; @@ -907,6 +923,7 @@ export const BrazeBannersSystemDisplay = ({ createReminder, dismissBanner, postMessageToBrazeBanner, + context, ]); // Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook From 1a2962c6c57e611e82934f77e52e5cc12b33048e Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 11:18:19 +0100 Subject: [PATCH 04/29] Add context prop to FeastContextualNudge component --- .../src/components/FeastContextualNudge.island.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 7d5098444de..ca51df02be8 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -203,6 +203,14 @@ export const FeastContextualNudge = ({ }} idApiUrl={idApiUrl} stage={stage} + context={{ + recipe, + recipeArticleTitle, + pageId, + isDev, + nudgeIndex, + darkMode: darkModeAvailable, + }} /> ); } From 0c4ea5e610ff88f1edb2e8a687540b1cf99ee8cd Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 12:53:45 +0100 Subject: [PATCH 05/29] Remove unused text color variables from FeastContextualNudge component --- .../src/components/FeastContextualNudge.island.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index ca51df02be8..64986cd8a88 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -21,8 +21,6 @@ import { useConfig } from './ConfigContext'; const FEAST_BG = '#F3F3E9'; const FEAST_BG_DARK = '#2B2B26'; -const FEAST_TEXT = sourcePalette.neutral[10]; -const FEAST_TEXT_DARK = sourcePalette.neutral[100]; const FEAST_SUBTEXT = sourcePalette.neutral[20]; const FEAST_SUBTEXT_DARK = sourcePalette.neutral[93]; const FEAST_GREEN = '#68773C'; @@ -34,14 +32,12 @@ const FEAST_BORDER_DARK = FEAST_GREEN; const lightVars = css` --feast-nudge-bg: ${FEAST_BG}; - --feast-nudge-heading: ${FEAST_TEXT}; --feast-nudge-subtext: ${FEAST_SUBTEXT}; --feast-nudge-border: ${FEAST_BORDER}; `; const darkVars = css` --feast-nudge-bg: ${FEAST_BG_DARK}; - --feast-nudge-heading: ${FEAST_TEXT_DARK}; --feast-nudge-subtext: ${FEAST_SUBTEXT_DARK}; --feast-nudge-border: ${FEAST_BORDER_DARK}; `; From d82581c256499d85a8378733bc6664bf07011a55 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 12:54:39 +0100 Subject: [PATCH 06/29] Refactor FeastContextualNudge component to replace subtext variables with text variables --- .../src/components/FeastContextualNudge.island.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 64986cd8a88..4f4bf55c924 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -21,8 +21,8 @@ import { useConfig } from './ConfigContext'; const FEAST_BG = '#F3F3E9'; const FEAST_BG_DARK = '#2B2B26'; -const FEAST_SUBTEXT = sourcePalette.neutral[20]; -const FEAST_SUBTEXT_DARK = sourcePalette.neutral[93]; +const FEAST_TEXT = sourcePalette.neutral[20]; +const FEAST_TEXT_DARK = sourcePalette.neutral[93]; const FEAST_GREEN = '#68773C'; const FEAST_GREEN_HOVER = '#4d5c2b'; const FEAST_BORDER = FEAST_GREEN; @@ -32,13 +32,13 @@ const FEAST_BORDER_DARK = FEAST_GREEN; const lightVars = css` --feast-nudge-bg: ${FEAST_BG}; - --feast-nudge-subtext: ${FEAST_SUBTEXT}; + --feast-nudge-text: ${FEAST_TEXT}; --feast-nudge-border: ${FEAST_BORDER}; `; const darkVars = css` --feast-nudge-bg: ${FEAST_BG_DARK}; - --feast-nudge-subtext: ${FEAST_SUBTEXT_DARK}; + --feast-nudge-text: ${FEAST_TEXT_DARK}; --feast-nudge-border: ${FEAST_BORDER_DARK}; `; @@ -110,7 +110,7 @@ const buttonWrapperStyles = css` const descriptionStyles = css` ${article15}; - color: var(--feast-nudge-subtext); + color: var(--feast-nudge-text); b { font-weight: bold; } From d18edc4fafdf541e4954e577ad338bc213186f08 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Wed, 24 Jun 2026 13:34:14 +0100 Subject: [PATCH 07/29] Wrap BrazeBannersSystemDisplay in a div with aria-description for accessibility and a margin --- .../FeastContextualNudge.island.tsx | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 4f4bf55c924..9e8c1ef1464 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -191,23 +191,31 @@ export const FeastContextualNudge = ({ const banner = braze?.getBanner(placementId) ?? null; if (banner && braze) { return ( - +
+ +
); } } From e6f095076b9afce78eb7865a6dac56bcffb46661 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Thu, 25 Jun 2026 16:32:07 +0100 Subject: [PATCH 08/29] Add placement management for Braze Banners System and update refresh logic --- .../src/lib/braze/BrazeBannersSystem.tsx | 53 ++++++++++++++++++- .../src/lib/braze/buildBrazeMessaging.ts | 6 ++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index cb7652a5133..dea5bdd2808 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -113,6 +113,42 @@ export enum BrazeBannersSystemPlacementId { FeastContextualNudge5 = 'dotcom-rendering_feast-contextual-nudge-5', } +/** + * Maximum number of placements per refresh request, as per Braze documentation: + * https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh + */ +const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; + +/** + * Maps each gu-island component name to the Braze Banner placement IDs it is + * responsible for rendering. When a gu-island element is absent from the + * server-rendered DOM, its placements are excluded from the refresh request, + * keeping requests within Braze's 10-placement cap and avoiding wasted + * rate-limit tokens on placements that cannot appear on the current page. + */ +const ISLAND_PLACEMENT_MAP: Record = { + StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], + SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], + FeastContextualNudge: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], +}; + +/** + * Determines which Braze Banner placement IDs are needed on the current page + * by checking which gu-island elements were rendered into the DOM server-side. + * Only placements whose corresponding island is present are included. + */ +export function getPagePlacements(): BrazeBannersSystemPlacementId[] { + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => + document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], + ); +} + /** * Trigger a refresh of Braze Banners System banners * "Each call to requestBannersRefresh consumes one token. If you attempt a refresh @@ -126,7 +162,20 @@ export enum BrazeBannersSystemPlacementId { * @param braze The Braze instance * @returns A promise that resolves when the refresh is complete */ -export function refreshBanners(braze: BrazeInstance): Promise { +export function refreshBanners( + braze: BrazeInstance, + placements: BrazeBannersSystemPlacementId[], +): Promise { + brazeBannersSystemLogger.info( + `πŸ”„ Requesting ${placements.length} placement(s): ${placements.join(', ')}`, + ); + + if (placements.length > BRAZE_MAX_PLACEMENTS_PER_REFRESH) { + brazeBannersSystemLogger.warn( + `⚠️ ${placements.length} placements requested, but Braze only processes the first ${BRAZE_MAX_PLACEMENTS_PER_REFRESH} per refresh request. See https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh`, + ); + } + let timeoutId: NodeJS.Timeout; // Create the Timeout Promise @@ -144,7 +193,7 @@ export function refreshBanners(braze: BrazeInstance): Promise { // Create the Braze Promise const brazeRequest = new Promise((resolve) => { braze.requestBannersRefresh( - Object.values(BrazeBannersSystemPlacementId), + placements, () => { brazeBannersSystemLogger.info('βœ… Refresh completed.'); clearTimeout(timeoutId); // Cancel the timeout diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts index d5341e41154..722b1906626 100644 --- a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts @@ -20,6 +20,7 @@ import { } from '../hasCurrentBrazeUser'; import { brazeBannersSystemLogger, + getPagePlacements, isDevelopmentDomain, refreshBanners, } from './BrazeBannersSystem'; @@ -146,7 +147,10 @@ export const buildBrazeMessaging = async ( // (Note that this method can only be called once per session.) // Since we want to suppress In-App Messages if a banner exists, we must // call requestBannersRefresh before openSession. - await refreshBanners(braze); + const placements = getPagePlacements(); + if (placements.length > 0) { + await refreshBanners(braze, placements); + } braze.openSession(); From 4802c0e8bcae7127556651527fabef6b8061f2ab Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:01:00 +0100 Subject: [PATCH 09/29] Enhance logging for Braze banners updates to provide clearer information --- dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts index 722b1906626..019632093cb 100644 --- a/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts +++ b/dotcom-rendering/src/lib/braze/buildBrazeMessaging.ts @@ -133,7 +133,10 @@ export const buildBrazeMessaging = async ( // This callback runs every time Braze has new data (initially empty, then populated) const subscriptionId = braze.subscribeToBannersUpdates( (banners) => { - brazeBannersSystemLogger.log('πŸ“’ Check:', banners); + brazeBannersSystemLogger.log( + 'πŸ“’ These Banners were updated:', + banners, + ); }, ); brazeBannersSystemLogger.info( From bcc02f4680b60862409a4ed31554818277862ccd Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:33:17 +0100 Subject: [PATCH 10/29] Add stale placement handling for Braze banners to improve fallback logic --- .../FeastContextualNudge.island.tsx | 15 +- .../src/lib/braze/BrazeBannersSystem.tsx | 148 ++++++++++++++++-- 2 files changed, 149 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 9e8c1ef1464..1e4240368f9 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -10,6 +10,7 @@ import { useEffect, useState } from 'react'; import { BrazeBannersSystemDisplay, BrazeBannersSystemPlacementId, + isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; import { useAB } from '../lib/useAB'; import { useBraze } from '../lib/useBraze'; @@ -188,7 +189,19 @@ export const FeastContextualNudge = ({ BrazeBannersSystemPlacementId[ `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId ]; - const banner = braze?.getBanner(placementId) ?? null; + + // Guard against stale placements: if the last requestBannersRefresh + // was rate-limited AND this placement has suppressOnStale: true in + // ISLAND_PLACEMENT_MAP, skip getBanner() and fall through to the + // native nudge below. + // + // With the current config (suppressOnStale: false for + // FeastContextualNudge) this check always passes β€” it exists for + // forward-compatibility if the config is ever changed to true. + const banner = !isPlacementStale(placementId) + ? (braze?.getBanner(placementId) ?? null) + : null; + if (banner && braze) { return (
= { - StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], - SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], - FeastContextualNudge: [ - BrazeBannersSystemPlacementId.FeastContextualNudge1, - BrazeBannersSystemPlacementId.FeastContextualNudge2, - BrazeBannersSystemPlacementId.FeastContextualNudge3, - BrazeBannersSystemPlacementId.FeastContextualNudge4, - BrazeBannersSystemPlacementId.FeastContextualNudge5, - ], +const ISLAND_PLACEMENT_MAP: Record< + string, + { + /** The Braze Banner placement IDs this island is responsible for. */ + ids: BrazeBannersSystemPlacementId[]; + /** + * Whether to suppress rendering when the last refresh was rate-limited. + * See the ISLAND_PLACEMENT_MAP JSDoc above for the full contract. + */ + suppressOnStale: boolean; + } +> = { + // MRR placements: suppress on stale β€” avoid showing outdated campaigns + // that could mislead readers or contradict their current eligibility. + StickyBottomBanner: { + ids: [BrazeBannersSystemPlacementId.Banner], + suppressOnStale: true, + }, + SlotBodyEnd: { + ids: [BrazeBannersSystemPlacementId.EndOfArticle], + suppressOnStale: true, + }, + // Feast placements: do NOT suppress on stale. FeastContextualNudge has a + // native "Download the app" card as a fallback β€” if the Braze banner is + // unavailable or stale, that fallback renders instead of a blank slot. + FeastContextualNudge: { + ids: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], + suppressOnStale: false, + }, }; /** @@ -144,11 +181,54 @@ const ISLAND_PLACEMENT_MAP: Record = { * Only placements whose corresponding island is present are included. */ export function getPagePlacements(): BrazeBannersSystemPlacementId[] { - return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => - document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap( + ([islandName, { ids }]) => + document.querySelector(`gu-island[name="${islandName}"]`) + ? ids + : [], ); } +/** + * Pre-computed set of placement IDs that opt-in to stale suppression. + * Derived once at module load time from ISLAND_PLACEMENT_MAP entries where + * suppressOnStale is true β€” avoids re-computing on every refreshBanners call. + */ +const STALE_SUPPRESSABLE_PLACEMENTS = new Set( + Object.values(ISLAND_PLACEMENT_MAP) + .filter(({ suppressOnStale }) => suppressOnStale) + .flatMap(({ ids }) => ids), +); + +/** + * Tracks placement IDs whose most-recent requestBannersRefresh call failed + * due to Braze's rate-limiter. When a placement is in this set, getBanner() + * may still return cached data from a previous session β€” but we actively + * suppress rendering to avoid showing outdated campaigns. + * + * Only placements with suppressOnStale: true in ISLAND_PLACEMENT_MAP are ever + * added here. Entries are removed when a subsequent refresh succeeds. + * + * This is module-level state. DCR has no SPA navigation β€” every page visit + * is a full reload β€” so this set always starts empty on each page. + */ +const stalePlacements = new Set(); + +/** + * Returns true if the given placement was marked stale because its last + * requestBannersRefresh call was rate-limited by Braze. When true, consumers + * should skip getBanner() and not render the Braze banner. + * + * Note: with the current config, FeastContextualNudge placements have + * suppressOnStale: false and will never be stale. This function is exported + * for forward-compatibility if that config changes in a future iteration. + * + * @param id The placement ID to check. + */ +export function isPlacementStale(id: BrazeBannersSystemPlacementId): boolean { + return stalePlacements.has(id); +} + /** * Trigger a refresh of Braze Banners System banners * "Each call to requestBannersRefresh consumes one token. If you attempt a refresh @@ -195,12 +275,40 @@ export function refreshBanners( braze.requestBannersRefresh( placements, () => { + // On success, lift stale status for any suppressable placements + // in this batch so a future re-refresh can restore them cleanly. + for (const id of placements) { + if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { + stalePlacements.delete(id); + } + } brazeBannersSystemLogger.info('βœ… Refresh completed.'); clearTimeout(timeoutId); // Cancel the timeout resolve(); }, () => { - brazeBannersSystemLogger.warn('⚠️ Refresh failed.'); + // The errorCallback fires when Braze's rate-limit tokens are + // exhausted. getBanner() still returns the last-cached banner, + // but that data may be outdated. For placements with + // suppressOnStale: true, mark them stale so that + // canShowBrazeBannersSystem (and direct consumers like + // FeastContextualNudge) will not render the cached banner. + const markedStale: BrazeBannersSystemPlacementId[] = []; + for (const id of placements) { + if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { + stalePlacements.add(id); + markedStale.push(id); + } + } + if (markedStale.length > 0) { + brazeBannersSystemLogger.warn( + `⚠️ Refresh failed (rate-limited). Marked ${markedStale.length} placement(s) as stale: ${markedStale.join(', ')}`, + ); + } else { + brazeBannersSystemLogger.warn( + '⚠️ Refresh failed (rate-limited). No placements marked stale.', + ); + } clearTimeout(timeoutId); // Cancel the timeout resolve(); }, @@ -282,6 +390,20 @@ export const canShowBrazeBannersSystem = async ( return { show: false }; } + /** + * Suppress this placement if it was marked stale by a failed refresh. + * getBanner() still returns cached data when rate-limited, so we must + * check stalePlacements *before* calling it to avoid rendering outdated + * campaigns. Only placements with suppressOnStale: true in + * ISLAND_PLACEMENT_MAP can ever be stale (currently: Banner, EndOfArticle). + */ + if (stalePlacements.has(placementId)) { + brazeBannersSystemLogger.info( + `Placement "${placementId}" is stale (last refresh was rate-limited). Not showing banner.`, + ); + return { show: false }; + } + /** * Banner for the placement ID. * It is an object of type Banner, if a banner with the given placement ID exists. From 25b88c1be94e3661fa6dd96ffca19d0eba66fb10 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 26 Jun 2026 11:38:54 +0100 Subject: [PATCH 11/29] Refactor Braze Banners System to implement per-placement stale suppression configuration --- .../FeastContextualNudge.island.tsx | 10 +- .../src/lib/braze/BrazeBannersSystem.tsx | 97 +++++++++---------- 2 files changed, 50 insertions(+), 57 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 1e4240368f9..56626543be7 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -192,12 +192,12 @@ export const FeastContextualNudge = ({ // Guard against stale placements: if the last requestBannersRefresh // was rate-limited AND this placement has suppressOnStale: true in - // ISLAND_PLACEMENT_MAP, skip getBanner() and fall through to the - // native nudge below. + // PLACEMENT_SUPPRESS_ON_STALE, skip getBanner() and fall through to + // the native nudge below. // - // With the current config (suppressOnStale: false for - // FeastContextualNudge) this check always passes β€” it exists for - // forward-compatibility if the config is ever changed to true. + // Each FeastContextualNudge placement ID has its own entry in + // PLACEMENT_SUPPRESS_ON_STALE β€” change any individual one to `true` + // to suppress that specific nudge on a failed refresh. const banner = !isPlacementStale(placementId) ? (braze?.getBanner(placementId) ?? null) : null; diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index 7331e6ebefb..1924a9eebe4 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -125,54 +125,46 @@ const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; * server-rendered DOM, its placements are excluded from the refresh request, * keeping requests within Braze's 10-placement cap and avoiding wasted * rate-limit tokens on placements that cannot appear on the current page. + */ +const ISLAND_PLACEMENT_MAP: Record = { + StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], + SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], + FeastContextualNudge: [ + BrazeBannersSystemPlacementId.FeastContextualNudge1, + BrazeBannersSystemPlacementId.FeastContextualNudge2, + BrazeBannersSystemPlacementId.FeastContextualNudge3, + BrazeBannersSystemPlacementId.FeastContextualNudge4, + BrazeBannersSystemPlacementId.FeastContextualNudge5, + ], +}; + +/** + * Per-placement stale-suppression config. * - * Each entry also carries a `suppressOnStale` flag that controls what happens - * when requestBannersRefresh is rate-limited by Braze: - * - * - true β†’ the placement IDs are added to `stalePlacements`. Guards in - * canShowBrazeBannersSystem (and direct consumers) will refuse to - * render the cached-but-potentially-outdated banner. - * - false β†’ the cached banner is still shown, or the component falls back to - * its own native UI. Use this when a graceful fallback exists or - * when showing a slightly-stale campaign is preferable to silence. + * When requestBannersRefresh is rate-limited by Braze, getBanner() still + * returns the last-cached banner. For each placement ID listed here as `true`, + * DCR will actively hide the banner rather than risk showing outdated content. + * Placement IDs omitted from this map (or set to `false`) fall through to + * their component's own fallback behaviour. * - * Default for new entries: false (permissive β€” show cached or fall back). + * This is the single place to change suppression behaviour for any placement. + * Default for new placements: omit the entry (treated as `false`). */ -const ISLAND_PLACEMENT_MAP: Record< - string, - { - /** The Braze Banner placement IDs this island is responsible for. */ - ids: BrazeBannersSystemPlacementId[]; - /** - * Whether to suppress rendering when the last refresh was rate-limited. - * See the ISLAND_PLACEMENT_MAP JSDoc above for the full contract. - */ - suppressOnStale: boolean; - } +const PLACEMENT_SUPPRESS_ON_STALE: Partial< + Record > = { // MRR placements: suppress on stale β€” avoid showing outdated campaigns // that could mislead readers or contradict their current eligibility. - StickyBottomBanner: { - ids: [BrazeBannersSystemPlacementId.Banner], - suppressOnStale: true, - }, - SlotBodyEnd: { - ids: [BrazeBannersSystemPlacementId.EndOfArticle], - suppressOnStale: true, - }, - // Feast placements: do NOT suppress on stale. FeastContextualNudge has a - // native "Download the app" card as a fallback β€” if the Braze banner is - // unavailable or stale, that fallback renders instead of a blank slot. - FeastContextualNudge: { - ids: [ - BrazeBannersSystemPlacementId.FeastContextualNudge1, - BrazeBannersSystemPlacementId.FeastContextualNudge2, - BrazeBannersSystemPlacementId.FeastContextualNudge3, - BrazeBannersSystemPlacementId.FeastContextualNudge4, - BrazeBannersSystemPlacementId.FeastContextualNudge5, - ], - suppressOnStale: false, - }, + [BrazeBannersSystemPlacementId.Banner]: true, + [BrazeBannersSystemPlacementId.EndOfArticle]: true, + // Feast placements: not suppressed β€” FeastContextualNudge falls back to + // its native "Download the app" card when no Braze banner is available. + // Set any of these to `true` to suppress that specific nudge on stale. + [BrazeBannersSystemPlacementId.FeastContextualNudge1]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge2]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge3]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge4]: false, + [BrazeBannersSystemPlacementId.FeastContextualNudge5]: false, }; /** @@ -181,23 +173,24 @@ const ISLAND_PLACEMENT_MAP: Record< * Only placements whose corresponding island is present are included. */ export function getPagePlacements(): BrazeBannersSystemPlacementId[] { - return Object.entries(ISLAND_PLACEMENT_MAP).flatMap( - ([islandName, { ids }]) => - document.querySelector(`gu-island[name="${islandName}"]`) - ? ids - : [], + return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => + document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], ); } /** * Pre-computed set of placement IDs that opt-in to stale suppression. - * Derived once at module load time from ISLAND_PLACEMENT_MAP entries where - * suppressOnStale is true β€” avoids re-computing on every refreshBanners call. + * Derived once at module load time from PLACEMENT_SUPPRESS_ON_STALE entries + * where the value is true β€” avoids re-computing on every refreshBanners call. */ const STALE_SUPPRESSABLE_PLACEMENTS = new Set( - Object.values(ISLAND_PLACEMENT_MAP) - .filter(({ suppressOnStale }) => suppressOnStale) - .flatMap(({ ids }) => ids), + ( + Object.entries(PLACEMENT_SUPPRESS_ON_STALE) as Array< + [BrazeBannersSystemPlacementId, boolean] + > + ) + .filter(([, suppress]) => suppress) + .map(([id]) => id), ); /** From 212591f71bca89df607f7cc2c009ad8a2563ee77 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 30 Jun 2026 17:11:13 +0100 Subject: [PATCH 12/29] Refactor getAdjustToken function for cleaner token retrieval in FeastContextualNudge --- .../src/components/FeastContextualNudge.island.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 56626543be7..934f9b54b04 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -48,9 +48,12 @@ const darkVars = css` const FEAST_ADJUST_TOKEN_PROD = '20wmhy68'; const FEAST_ADJUST_TOKEN_CODE = '20o7ykck'; +const getAdjustToken = (stage: StageType): string => { + return stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE; +}; + const buildFeastLink = (recipeId: string, stage: StageType): string => { - const token = - stage === 'PROD' ? FEAST_ADJUST_TOKEN_PROD : FEAST_ADJUST_TOKEN_CODE; + const token = getAdjustToken(stage); return `https://guardian-feast.go.link/recipe/${encodeURIComponent( recipeId, )}?adj_t=${encodeURIComponent(token)}`; @@ -226,6 +229,7 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, darkMode: darkModeAvailable, + adjustToken: getAdjustToken(stage), }} />
From de918efee542cd4f63f36e318d58213bfd14ffcb Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Mon, 13 Jul 2026 11:16:48 +0100 Subject: [PATCH 13/29] Add nudgeMinHeightStyles to prevent content shift during Braze banner loading --- .../FeastContextualNudge.island.tsx | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 934f9b54b04..39b5c998fc5 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -82,6 +82,40 @@ const cardGridStyles = css` } `; +// ── CLS mitigation ──────────────────────────────────────────────────────────── + +/** + * Reserves vertical space for the nudge so that the async swap between the + * native fallback card and a Braze banner (see the `useBraze` fetch below) + * doesn't shift surrounding content once the placement is already visible to + * the reader. + * + * Values are the rendered height of the native fallback card (fixed copy, so + * the height is deterministic), measured via Storybook at each named + * breakpoint where the description text wraps to a different number of + * lines: + * - < mobileMedium (375px): 3 lines β†’ 111px + * - mobileMedium–phablet (375–659px): 2 lines β†’ 90px + * - >= phablet (660px): 1 line β†’ 69px + * + * A small buffer is added to absorb minor font-metric differences (e.g. + * webfont vs. fallback font during load). + * + * This only guarantees no shift when a Braze banner is the same height or + * shorter. Braze banners can set their own `minHeight` custom property (see + * `BrazeBannersSystem.tsx`) to match or exceed these values so their content + * doesn't overflow the reserved space either. + */ +const nudgeMinHeightStyles = css` + min-height: 112px; + ${from.mobileMedium} { + min-height: 92px; + } + ${from.phablet} { + min-height: 72px; + } +`; + // ── Card styles ─────────────────────────────────────────────────────────────── const showcaseCardStyles = css` @@ -210,9 +244,12 @@ export const FeastContextualNudge = ({
Date: Fri, 17 Jul 2026 16:56:11 +0100 Subject: [PATCH 14/29] Build Feast deep-link URL using the correct Adjust token in FeastContextualNudge --- .../src/components/FeastContextualNudge.island.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 39b5c998fc5..0906b1d4fea 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -219,6 +219,9 @@ export const FeastContextualNudge = ({ if (!isVariant) return null; + // Build Feast deep-link URL for the current recipe, using the correct Adjust token + const feastLink = buildFeastLink(feastId, stage); + // If idApiUrl is defined and Braze has a banner for this placement slot, // render the Braze banner instead of the native nudge. if (idApiUrl !== undefined) { @@ -266,7 +269,7 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, darkMode: darkModeAvailable, - adjustToken: getAdjustToken(stage), + feastLink, }} />
@@ -312,7 +315,7 @@ export const FeastContextualNudge = ({ Date: Fri, 17 Jul 2026 16:59:15 +0100 Subject: [PATCH 15/29] Temporarily force nudge rendering for testing purposes in FeastContextualNudge --- .../src/components/FeastContextualNudge.island.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 0906b1d4fea..85e7201e066 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -187,10 +187,14 @@ export const FeastContextualNudge = ({ idApiUrl, }: FeastContextualNudgeProps) => { const abTests = useAB(); - const isVariant = + let isVariant = abTests?.isUserInTestGroup('feast-recipe-nudge-v2', 'variant-1') ?? false; + // TEMP ONLY FOR DEVELOPMENT: force the nudge to render for testing purposes, even if the user is not in the AB test variant. + isVariant = true; + // TEMP ONLY FOR DEVELOPMENT: force the nudge to render for testing purposes, even if the user is not in the AB test variant. + const { darkModeAvailable, renderingTarget } = useConfig(); const { braze } = useBraze(idApiUrl ?? '', renderingTarget); From fc1e23cadec41649695883258b81e2d664451d4e Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Fri, 17 Jul 2026 18:16:33 +0100 Subject: [PATCH 16/29] Revert "Build Feast deep-link URL using the correct Adjust token in FeastContextualNudge" This reverts commit e279321a91222ba0a706cd04f153c7f3fab611f6. --- .../src/components/FeastContextualNudge.island.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 85e7201e066..058a964f286 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -223,9 +223,6 @@ export const FeastContextualNudge = ({ if (!isVariant) return null; - // Build Feast deep-link URL for the current recipe, using the correct Adjust token - const feastLink = buildFeastLink(feastId, stage); - // If idApiUrl is defined and Braze has a banner for this placement slot, // render the Braze banner instead of the native nudge. if (idApiUrl !== undefined) { @@ -273,7 +270,7 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, darkMode: darkModeAvailable, - feastLink, + adjustToken: getAdjustToken(stage), }} />
@@ -319,7 +316,7 @@ export const FeastContextualNudge = ({ Date: Fri, 17 Jul 2026 22:19:30 +0100 Subject: [PATCH 17/29] Implement "Saved from web" feature for Feast recipes with API integration --- .../FeastContextualNudge.island.tsx | 19 ++++ .../src/lib/braze/BrazeBannersSystem.tsx | 70 ++++++++++++ .../src/lib/feast/savedFromWeb.ts | 105 +++++++++++++++++ .../src/server/handler.savedFromWeb.ts | 106 ++++++++++++++++++ dotcom-rendering/src/server/server.dev.ts | 7 ++ dotcom-rendering/src/server/server.prod.ts | 7 ++ 6 files changed, 314 insertions(+) create mode 100644 dotcom-rendering/src/lib/feast/savedFromWeb.ts create mode 100644 dotcom-rendering/src/server/handler.savedFromWeb.ts diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 058a964f286..f94b1d23a72 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -12,7 +12,9 @@ import { BrazeBannersSystemPlacementId, isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; +import { getFeastSavedFromTheWebRecipes } from '../lib/feast/savedFromWeb'; import { useAB } from '../lib/useAB'; +import { useAuthStatus } from '../lib/useAuthStatus'; import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; import type { RecipeBlockElement } from '../types/content'; @@ -221,6 +223,22 @@ export const FeastContextualNudge = ({ } }, [feastId, title, pageId, isDev, isVariant]); + // Whether this recipe is already in the reader's "Saved from web" list. + // `getFeastSavedFromTheWebRecipes` is memoized by access token, so no + // matter how many FeastContextualNudge islands on this page call it as + // they hydrate (each is deferred `until: 'visible'`), only one network + // request for the reader's full saved-recipe list is ever made. + const authStatus = useAuthStatus(); + const [isRecipeSaved, setIsRecipeSaved] = useState(false); + useEffect(() => { + if (authStatus.kind !== 'SignedIn') return; + void getFeastSavedFromTheWebRecipes( + authStatus.accessToken.accessToken, + ).then((savedRecipeIds) => { + setIsRecipeSaved(savedRecipeIds.has(feastId)); + }); + }, [authStatus, feastId]); + if (!isVariant) return null; // If idApiUrl is defined and Braze has a banner for this placement slot, @@ -271,6 +289,7 @@ export const FeastContextualNudge = ({ nudgeIndex, darkMode: darkModeAvailable, adjustToken: getAdjustToken(stage), + isRecipeSaved, }} /> diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index 1924a9eebe4..a188a91cb40 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -12,6 +12,7 @@ import { useConfig } from '../../components/ConfigContext'; import { isProd } from '../../components/marketing/lib/stage'; import type { StageType } from '../../types/config'; import type { TagType } from '../../types/tag'; +import { addFeastRecipeToSavedFromWebList } from '../feast/savedFromWeb'; import { getAuthState, getOptionsHeaders } from '../identity'; import type { CandidateConfig, CanShowResult } from '../messagePicker'; import { useAuthStatus } from '../useAuthStatus'; @@ -483,6 +484,7 @@ enum BrazeBannersSystemMessageType { NavigateToUrl = 'BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL', DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER', GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT', + SaveFeastRecipeById = 'BRAZE_BANNERS_SYSTEM:SAVE_FEAST_RECIPE_BY_ID', } /** @@ -654,6 +656,41 @@ export const BrazeBannersSystemDisplay = ({ threshold: 0, }); + /** + * Save a Feast recipe by its Id on the "Saved from the Web" recipes list on the App. + * Proxied through DCR's own server (`/api/saved-from-web/:recipeId`), + * which forwards the reader's bearer token to the Feast API. Idempotent: + * saving the same recipe id twice has no extra effect. + * @param feastRecipeId The ID of the Feast recipe to save + * @returns A promise that resolves to true if the add process was successful, or false if it failed or the user is not signed in + */ + const saveFeastRecipeById = useCallback( + async (feastRecipeId: string): Promise => { + if (authStatus.kind === 'SignedIn') { + const success = await addFeastRecipeToSavedFromWebList( + authStatus.accessToken.accessToken, + feastRecipeId, + ); + + if (success) { + brazeBannersSystemLogger.info( + 'Successfully saved recipe to the "Saved from web" list:', + feastRecipeId, + ); + } else { + brazeBannersSystemLogger.warn( + 'Failed to save recipe to the "Saved from web" list:', + feastRecipeId, + ); + } + + return success; + } + return false; + }, + [authStatus], + ); + /** * Subscribes the user to a newsletter via the Identity API. * Only attempts to subscribe if the user is signed in and a newsletter ID is provided. @@ -922,6 +959,10 @@ export const BrazeBannersSystemDisplay = ({ | { type: BrazeBannersSystemMessageType.GetContext; } + | { + type: BrazeBannersSystemMessageType.SaveFeastRecipeById; + feastRecipeId?: string; + } >, ) => { if ( @@ -1071,6 +1112,34 @@ export const BrazeBannersSystemDisplay = ({ }, ); break; + case BrazeBannersSystemMessageType.SaveFeastRecipeById: { + meta.braze.logBannerClick( + meta.banner, + 'save_feast_recipe_button', + ); + const { feastRecipeId } = event.data; + if (feastRecipeId !== undefined) { + void saveFeastRecipeById(feastRecipeId).then( + (success) => { + postMessageToBrazeBanner( + BrazeBannersSystemMessageType.SaveFeastRecipeById, + { + success, + }, + ); + meta.braze.logCustomEvent( + 'braze_banner_save_feast_recipe', + { + placementId: meta.banner.placementId, + feastRecipeId, + success, + }, + ); + }, + ); + } + break; + } } }; @@ -1088,6 +1157,7 @@ export const BrazeBannersSystemDisplay = ({ dismissBanner, postMessageToBrazeBanner, context, + saveFeastRecipeById, ]); // Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook diff --git a/dotcom-rendering/src/lib/feast/savedFromWeb.ts b/dotcom-rendering/src/lib/feast/savedFromWeb.ts new file mode 100644 index 00000000000..660f10fbd53 --- /dev/null +++ b/dotcom-rendering/src/lib/feast/savedFromWeb.ts @@ -0,0 +1,105 @@ +import { makeMemoizedFunction } from '../memoize'; + +/** + * DCR's own server-side proxy for the Feast API's "Saved from web" + * endpoints (see `handler.savedFromWeb.ts`). The browser never talks to the + * Feast API directly: DCR's Node server forwards the reader's bearer token + * straight through, which sidesteps needing Feast API-side CORS + * configuration for browser origins. + */ +const SAVED_FROM_WEB_PROXY_PATH = '/api/saved-from-web'; + +/** + * Fetches the reader's full "Saved from web" recipe id list. + * + * Memoized by access token: performs only one network request per page + * load, no matter how many `FeastContextualNudge` islands call it + * independently as they hydrate (each is deferred `until: 'visible'` and + * hydrates at its own time, so there is no single moment where "the page" + * loads). This mirrors how `useAuthStatus` is called independently by many + * islands elsewhere in DCR, relying on caching rather than a single + * page-level coordination point. + */ +export const getFeastSavedFromTheWebRecipes = makeMemoizedFunction( + async (accessToken: string): Promise> => { + try { + const response = await fetch(SAVED_FROM_WEB_PROXY_PATH, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-GU-IS-OAUTH': 'true', + }, + }); + + if (!response.ok) { + console.error( + '[getFeastSavedFromTheWebRecipes] Failed to fetch saved recipes:', + response.status, + response.statusText, + ); + return new Set(); + } + + const data: unknown = await response.json(); + if (!Array.isArray(data)) { + console.error( + '[getFeastSavedFromTheWebRecipes] Unexpected response shape:', + data, + ); + return new Set(); + } + + return new Set( + data.filter((id): id is string => typeof id === 'string'), + ); + } catch (error) { + console.error( + '[getFeastSavedFromTheWebRecipes] Error fetching saved recipes:', + error, + ); + return new Set(); + } + }, +); + +/** + * Adds a recipe to the reader's "Saved from web" list, creating the list on + * first use. Idempotent: adding the same recipe id twice has no extra + * effect. + * + * Not memoized: each call is a distinct write, not an idempotent read to + * dedupe. + */ +export const addFeastRecipeToSavedFromWebList = async ( + accessToken: string, + recipeId: string, +): Promise => { + try { + const response = await fetch( + `${SAVED_FROM_WEB_PROXY_PATH}/${encodeURIComponent(recipeId)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-GU-IS-OAUTH': 'true', + }, + }, + ); + + if (!response.ok) { + console.error( + '[addFeastRecipeToSavedFromWebList] Failed to save recipe:', + response.status, + response.statusText, + ); + return false; + } + + return true; + } catch (error) { + console.error( + '[addFeastRecipeToSavedFromWebList] Error saving recipe:', + error, + ); + return false; + } +}; diff --git a/dotcom-rendering/src/server/handler.savedFromWeb.ts b/dotcom-rendering/src/server/handler.savedFromWeb.ts new file mode 100644 index 00000000000..4e96c5a753c --- /dev/null +++ b/dotcom-rendering/src/server/handler.savedFromWeb.ts @@ -0,0 +1,106 @@ +import type { RequestHandler } from 'express'; +import { isProd } from '../components/marketing/lib/stage'; + +/** + * Path of the Feast API's "Saved from web" endpoints. See ADR 0008 (Feast + * API repo) for the design rationale behind this feature. + */ +const FEAST_API_SAVED_FROM_WEB_PATH = '/v2/saved-from-web'; + +const getFeastApiOrigin = (): string => { + const stage = process.env.GU_STAGE?.toUpperCase(); + return isProd(stage) + ? 'https://recipes.guardianapis.com' + : 'https://recipes.code.dev-guardianapis.com'; +}; + +type ProxyResult = { status: number; body: unknown }; + +/** + * Proxies a request to the Feast API's "Saved from web" endpoints, + * forwarding the reader's own bearer token as-is. DCR does not hold any + * separate credentials for this call: the token the reader already has is + * what the Feast API uses to authorise the request. The browser never talks + * to the Feast API directly, which avoids needing Feast API-side CORS + * configuration for DCR's origins. + */ +const proxyToFeastApi = async ( + path: string, + method: 'GET' | 'PUT', + authorizationHeader: string | undefined, +): Promise => { + if (!authorizationHeader) { + return { + status: 401, + body: { message: 'Missing Authorization header' }, + }; + } + + const response = await fetch(`${getFeastApiOrigin()}${path}`, { + method, + headers: { + Authorization: authorizationHeader, + }, + }); + + if (response.status === 204) { + return { status: response.status, body: undefined }; + } + + const body: unknown = await response.json().catch(() => undefined); + return { status: response.status, body }; +}; + +/** + * GET /api/saved-from-web + * Proxies to the Feast API to fetch the reader's full list of recipe ids + * saved to their "Saved from web" list. + */ +export const handleGetSavedFromWeb: RequestHandler = async (req, res) => { + try { + const { status, body } = await proxyToFeastApi( + FEAST_API_SAVED_FROM_WEB_PATH, + 'GET', + req.headers.authorization, + ); + res.status(status).json(body ?? []); + } catch (error) { + console.error( + '[handleGetSavedFromWeb] Error proxying to Feast API:', + error, + ); + res.status(502).json({ message: 'Error contacting Feast API' }); + } +}; + +/** + * PUT /api/saved-from-web/:recipeId + * Proxies to the Feast API to add a recipe to the reader's "Saved from web" + * list, creating the list on first use. Idempotent. + */ +export const handlePutSavedFromWebRecipe: RequestHandler = async (req, res) => { + const { recipeId } = req.params; + if (!recipeId || Array.isArray(recipeId)) { + res.status(400).json({ message: 'Missing recipeId' }); + return; + } + + try { + const { status, body } = await proxyToFeastApi( + `${FEAST_API_SAVED_FROM_WEB_PATH}/${encodeURIComponent(recipeId)}`, + 'PUT', + req.headers.authorization, + ); + if (body === undefined) { + res.status(status).end(); + } else { + res.status(status).json(body); + } + } catch (error) { + console.error( + '[handlePutSavedFromWebRecipe] Error proxying to Feast API:', + error, + ); + res.status(502).json({ message: 'Error contacting Feast API' }); + } +}; diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts index a8915867297..49c8cd04191 100644 --- a/dotcom-rendering/src/server/server.dev.ts +++ b/dotcom-rendering/src/server/server.dev.ts @@ -18,6 +18,10 @@ import { handleAppsAssets } from './handler.assets.apps'; import { handleEditionsCrossword } from './handler.editionsCrossword'; import { handleFootballMatchDayEmbed } from './handler.footballMatchDayEmbed'; import { handleFront, handleTagPage } from './handler.front.web'; +import { + handleGetSavedFromWeb, + handlePutSavedFromWebRecipe, +} from './handler.savedFromWeb'; import { handleAppsFootballMatchPage, handleCricketMatchPage, @@ -148,6 +152,9 @@ renderer.post('/FootballMatchDayEmbed', handleFootballMatchDayEmbed); renderer.get('/assets/rendered-items-assets', handleAppsAssets); +renderer.get('/api/saved-from-web', handleGetSavedFromWeb); +renderer.put('/api/saved-from-web/:recipeId', handlePutSavedFromWebRecipe); + const router = Router(); router.use('/pages', pages); router.use('/targets', targets); diff --git a/dotcom-rendering/src/server/server.prod.ts b/dotcom-rendering/src/server/server.prod.ts index db44443d436..a8a11f48b2b 100644 --- a/dotcom-rendering/src/server/server.prod.ts +++ b/dotcom-rendering/src/server/server.prod.ts @@ -19,6 +19,10 @@ import { handleAppsAssets } from './handler.assets.apps'; import { handleEditionsCrossword } from './handler.editionsCrossword'; import { handleFootballMatchDayEmbed } from './handler.footballMatchDayEmbed'; import { handleFront, handleTagPage } from './handler.front.web'; +import { + handleGetSavedFromWeb, + handlePutSavedFromWebRecipe, +} from './handler.savedFromWeb'; import { handleAppsFootballMatchPage, handleCricketMatchPage, @@ -75,6 +79,9 @@ export const prodServer = (): void => { app.get('/assets/rendered-items-assets', handleAppsAssets); + app.get('/api/saved-from-web', handleGetSavedFromWeb); + app.put('/api/saved-from-web/:recipeId', handlePutSavedFromWebRecipe); + // All params to error handlers must be declared for express to identify them as error middleware // https://expressjs.com/en/api.html#:~:text=Error%2Dhandling%20middleware%20always,see%3A%20Error%20handling // eslint-disable-next-line @typescript-eslint/no-unused-vars -- all params to error handlers must be declared From 1fe6801326c5c4b0a5098ef397f3afad69e53c49 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 21 Jul 2026 10:39:29 +0100 Subject: [PATCH 18/29] Add support for batch fetching "Saved from web" recipe states in FeastContextualNudge --- .../FeastContextualNudge.island.tsx | 12 ++- .../FeastContextualNudge.stories.tsx | 1 + dotcom-rendering/src/lib/ArticleRenderer.tsx | 21 ++++- .../src/lib/feast/savedFromWeb.ts | 92 +++++++++++++++---- .../src/server/handler.savedFromWeb.ts | 36 +++++++- 5 files changed, 140 insertions(+), 22 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index f94b1d23a72..ad39c739261 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -165,6 +165,14 @@ type FeastContextualNudgeProps = { isDev: boolean; nudgeIndex: number; idApiUrl: string | undefined; + /** + * Every recipe id that will get a nudge on this page (at most 5). Shared + * across all FeastContextualNudge instances so that whichever one + * hydrates first fetches the reader's "Saved from web" state for the + * whole batch in a single request, rather than each nudge querying just + * its own recipe id separately. + */ + allNudgeRecipeIds: string[]; }; /** @@ -187,6 +195,7 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, idApiUrl, + allNudgeRecipeIds, }: FeastContextualNudgeProps) => { const abTests = useAB(); let isVariant = @@ -234,10 +243,11 @@ export const FeastContextualNudge = ({ if (authStatus.kind !== 'SignedIn') return; void getFeastSavedFromTheWebRecipes( authStatus.accessToken.accessToken, + allNudgeRecipeIds, ).then((savedRecipeIds) => { setIsRecipeSaved(savedRecipeIds.has(feastId)); }); - }, [authStatus, feastId]); + }, [authStatus, feastId, allNudgeRecipeIds]); if (!isVariant) return null; diff --git a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx index 5897eaec5fa..32a85dc1283 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx @@ -55,6 +55,7 @@ const meta = { isDev: true, nudgeIndex: 1, idApiUrl: undefined, + allNudgeRecipeIds: [mockRecipe.id], }, parameters: { chromatic: { viewports: [375, 740, 980] }, diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx index d7fd529642a..7ef2c13fccd 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx @@ -168,11 +168,29 @@ export const ArticleRenderer = ({ const MAX_NUDGES = 5; const interval = Math.ceil(sections.length / MAX_NUDGES); - for (const section of sections) { + const sectionsWithNudgeIndex = sections.map((section) => { const position = section.index + 1; // 1-based const nudgeIndex = position % interval === 0 ? position / interval : null; + return { section, nudgeIndex }; + }); + /** + * Every recipe id that will get a nudge on this page, known upfront + * (at most MAX_NUDGES). Passed to every FeastContextualNudge + * instance so that whichever one hydrates first can fetch the + * reader's "Saved from web" state for the whole batch in a single + * request (see `getFeastSavedFromTheWebRecipes`), rather than each + * nudge querying just its own recipe id separately. + */ + const allNudgeRecipeIds = sectionsWithNudgeIndex + .filter( + ({ section, nudgeIndex }) => + section.recipe !== undefined && nudgeIndex !== null, + ) + .map(({ section }) => section.recipe!.id); + + for (const { section, nudgeIndex } of sectionsWithNudgeIndex) { result.push( {section.subheadingEl} @@ -189,6 +207,7 @@ export const ArticleRenderer = ({ recipeArticleTitle={section.recipeArticleTitle} nudgeIndex={nudgeIndex} idApiUrl={idApiUrl} + allNudgeRecipeIds={allNudgeRecipeIds} /> )} diff --git a/dotcom-rendering/src/lib/feast/savedFromWeb.ts b/dotcom-rendering/src/lib/feast/savedFromWeb.ts index 660f10fbd53..0e4a617c90e 100644 --- a/dotcom-rendering/src/lib/feast/savedFromWeb.ts +++ b/dotcom-rendering/src/lib/feast/savedFromWeb.ts @@ -10,25 +10,52 @@ import { makeMemoizedFunction } from '../memoize'; const SAVED_FROM_WEB_PROXY_PATH = '/api/saved-from-web'; /** - * Fetches the reader's full "Saved from web" recipe id list. + * Upper bound on how many recipe ids can be requested in one call. Mirrors + * the Feast API's own `MaxSavedFromWebIdsPerRequest` limit (currently 5, + * which also happens to match DCR's own `MAX_NUDGES` cap on how many + * FeastContextualNudge placements can appear on a single article). + */ +const MAX_SAVED_FROM_WEB_IDS_PER_REQUEST = 5; + +/** + * Builds a single string key combining the access token and the requested + * recipe ids (deduped and sorted, so equivalent id sets in a different + * order still hit the same cache entry), for use with `makeMemoizedFunction` + * (which caches by a single primitive key, not by array/object reference). + */ +const buildCacheKey = (accessToken: string, recipeIds: string[]): string => { + const sortedIds = Array.from(new Set(recipeIds)).sort(); + return `${accessToken}|${sortedIds.join(',')}`; +}; + +type SavedFromWebItem = { recipeId: string; lastModified: string }; + +/** + * Checks which of the given recipe ids are already saved to the reader's + * "Saved from web" list. The underlying `GET /api/saved-from-web?ids=...` + * request returns only the ids that are saved, so an id absent from the + * response is simply not saved (not an error). * - * Memoized by access token: performs only one network request per page - * load, no matter how many `FeastContextualNudge` islands call it - * independently as they hydrate (each is deferred `until: 'visible'` and - * hydrates at its own time, so there is no single moment where "the page" - * loads). This mirrors how `useAuthStatus` is called independently by many - * islands elsewhere in DCR, relying on caching rather than a single - * page-level coordination point. + * Memoized by (access token + sorted recipe ids): performs only one network + * request per unique combination, no matter how many `FeastContextualNudge` + * islands call it independently as they hydrate (each is deferred + * `until: 'visible'` and hydrates at its own time). As long as every nudge + * on a page is passed the same full list of recipe ids (see + * `ArticleRenderer`), they'll all share the one in-flight/resolved request. */ -export const getFeastSavedFromTheWebRecipes = makeMemoizedFunction( - async (accessToken: string): Promise> => { +const fetchSavedFromWebRecipes = makeMemoizedFunction( + async (cacheKey: string): Promise> => { + const [accessToken = '', idsParam = ''] = cacheKey.split('|'); try { - const response = await fetch(SAVED_FROM_WEB_PROXY_PATH, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'X-GU-IS-OAUTH': 'true', + const response = await fetch( + `${SAVED_FROM_WEB_PROXY_PATH}?ids=${encodeURIComponent(idsParam)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-GU-IS-OAUTH': 'true', + }, }, - }); + ); if (!response.ok) { console.error( @@ -49,7 +76,9 @@ export const getFeastSavedFromTheWebRecipes = makeMemoizedFunction( } return new Set( - data.filter((id): id is string => typeof id === 'string'), + (data as SavedFromWebItem[]) + .filter((item) => typeof item.recipeId === 'string') + .map((item) => item.recipeId), ); } catch (error) { console.error( @@ -61,6 +90,37 @@ export const getFeastSavedFromTheWebRecipes = makeMemoizedFunction( }, ); +/** + * Checks which of the given recipe ids are already saved to the reader's + * "Saved from web" list. See `fetchSavedFromWebRecipes` for the memoization + * behaviour that keeps this to one network request per page. + * @param accessToken The reader's Okta access token + * @param recipeIds The recipe ids to check (capped at `MaxSavedFromWebIdsPerRequest` on the Feast API; longer lists are truncated) + */ +export const getFeastSavedFromTheWebRecipes = ( + accessToken: string, + recipeIds: string[], +): Promise> => { + if (recipeIds.length === 0) { + return Promise.resolve(new Set()); + } + + const cappedRecipeIds = + recipeIds.length > MAX_SAVED_FROM_WEB_IDS_PER_REQUEST + ? recipeIds.slice(0, MAX_SAVED_FROM_WEB_IDS_PER_REQUEST) + : recipeIds; + + if (cappedRecipeIds.length !== recipeIds.length) { + console.error( + `[getFeastSavedFromTheWebRecipes] Too many recipe ids requested (${recipeIds.length}), truncating to ${MAX_SAVED_FROM_WEB_IDS_PER_REQUEST}`, + ); + } + + return fetchSavedFromWebRecipes( + buildCacheKey(accessToken, cappedRecipeIds), + ); +}; + /** * Adds a recipe to the reader's "Saved from web" list, creating the list on * first use. Idempotent: adding the same recipe id twice has no extra diff --git a/dotcom-rendering/src/server/handler.savedFromWeb.ts b/dotcom-rendering/src/server/handler.savedFromWeb.ts index 4e96c5a753c..f454ef740d3 100644 --- a/dotcom-rendering/src/server/handler.savedFromWeb.ts +++ b/dotcom-rendering/src/server/handler.savedFromWeb.ts @@ -52,14 +52,42 @@ const proxyToFeastApi = async ( }; /** - * GET /api/saved-from-web - * Proxies to the Feast API to fetch the reader's full list of recipe ids - * saved to their "Saved from web" list. + * Upper bound on how many recipe ids can be requested in one call to + * `GET /api/saved-from-web`. Mirrors the Feast API's own + * `MaxSavedFromWebIdsPerRequest` limit, so we can reject an over-long + * request early with a clear 400 rather than relying on a round trip to the + * Feast API to find out. + */ +const MAX_SAVED_FROM_WEB_IDS_PER_REQUEST = 5; + +/** + * GET /api/saved-from-web?ids=a,b,c + * Proxies to the Feast API to check which of the given recipe ids are + * present in the reader's "Saved from web" list. Returns only the ids that + * are saved (as `{ recipeId, lastModified }[]`); ids not present are simply + * omitted from the response. */ export const handleGetSavedFromWeb: RequestHandler = async (req, res) => { + const idsParam = req.query.ids; + if (typeof idsParam !== 'string' || idsParam.length === 0) { + res.status(400).json({ + message: + 'missing required "ids" query parameter (comma-separated recipe ids)', + }); + return; + } + + const recipeIds = idsParam.split(','); + if (recipeIds.length > MAX_SAVED_FROM_WEB_IDS_PER_REQUEST) { + res.status(400).json({ + message: `too many ids requested, max is ${MAX_SAVED_FROM_WEB_IDS_PER_REQUEST}`, + }); + return; + } + try { const { status, body } = await proxyToFeastApi( - FEAST_API_SAVED_FROM_WEB_PATH, + `${FEAST_API_SAVED_FROM_WEB_PATH}?ids=${encodeURIComponent(idsParam)}`, 'GET', req.headers.authorization, ); From e0570e4ae520633b56f4b740fee389f46b1ebaa3 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 21 Jul 2026 10:51:54 +0100 Subject: [PATCH 19/29] Refactor Feast saved recipes caching to use user ID as key and optimize network requests --- .../FeastContextualNudge.island.tsx | 5 +- .../src/lib/feast/savedFromWeb.ts | 68 ++++++++++--------- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index ad39c739261..7f0b1cd34c3 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -233,15 +233,16 @@ export const FeastContextualNudge = ({ }, [feastId, title, pageId, isDev, isVariant]); // Whether this recipe is already in the reader's "Saved from web" list. - // `getFeastSavedFromTheWebRecipes` is memoized by access token, so no + // `getFeastSavedFromTheWebRecipes` caches by user id + recipe ids, so no // matter how many FeastContextualNudge islands on this page call it as // they hydrate (each is deferred `until: 'visible'`), only one network - // request for the reader's full saved-recipe list is ever made. + // request for the batch of recipe ids on this page is ever made. const authStatus = useAuthStatus(); const [isRecipeSaved, setIsRecipeSaved] = useState(false); useEffect(() => { if (authStatus.kind !== 'SignedIn') return; void getFeastSavedFromTheWebRecipes( + authStatus.idToken.claims.sub, authStatus.accessToken.accessToken, allNudgeRecipeIds, ).then((savedRecipeIds) => { diff --git a/dotcom-rendering/src/lib/feast/savedFromWeb.ts b/dotcom-rendering/src/lib/feast/savedFromWeb.ts index 0e4a617c90e..024f009a9b4 100644 --- a/dotcom-rendering/src/lib/feast/savedFromWeb.ts +++ b/dotcom-rendering/src/lib/feast/savedFromWeb.ts @@ -1,5 +1,3 @@ -import { makeMemoizedFunction } from '../memoize'; - /** * DCR's own server-side proxy for the Feast API's "Saved from web" * endpoints (see `handler.savedFromWeb.ts`). The browser never talks to the @@ -18,34 +16,36 @@ const SAVED_FROM_WEB_PROXY_PATH = '/api/saved-from-web'; const MAX_SAVED_FROM_WEB_IDS_PER_REQUEST = 5; /** - * Builds a single string key combining the access token and the requested - * recipe ids (deduped and sorted, so equivalent id sets in a different - * order still hit the same cache entry), for use with `makeMemoizedFunction` - * (which caches by a single primitive key, not by array/object reference). + * In-flight/resolved request cache, keyed by `userId:sortedRecipeIds`. + * + * Deliberately keyed by the reader's user id, not their access token: the + * token is only ever used transiently to make the one underlying fetch and + * is not retained here, which keeps it out of any long-lived module-level + * state. This is module-level state that resets on every full page + * load β€” DCR has no SPA navigation, so a page refresh always starts with an + * empty cache, same as `stalePlacements` in `BrazeBannersSystem.tsx`. */ -const buildCacheKey = (accessToken: string, recipeIds: string[]): string => { - const sortedIds = Array.from(new Set(recipeIds)).sort(); - return `${accessToken}|${sortedIds.join(',')}`; -}; +const savedFromWebCache = new Map>>(); type SavedFromWebItem = { recipeId: string; lastModified: string }; /** - * Checks which of the given recipe ids are already saved to the reader's - * "Saved from web" list. The underlying `GET /api/saved-from-web?ids=...` - * request returns only the ids that are saved, so an id absent from the - * response is simply not saved (not an error). - * - * Memoized by (access token + sorted recipe ids): performs only one network - * request per unique combination, no matter how many `FeastContextualNudge` - * islands call it independently as they hydrate (each is deferred - * `until: 'visible'` and hydrates at its own time). As long as every nudge - * on a page is passed the same full list of recipe ids (see - * `ArticleRenderer`), they'll all share the one in-flight/resolved request. + * Performs (and caches) the underlying request for a given cache key. The + * cache entry is written synchronously, before the fetch resolves, so + * concurrent callers in the same tick share the one in-flight promise + * rather than each triggering their own request. */ -const fetchSavedFromWebRecipes = makeMemoizedFunction( - async (cacheKey: string): Promise> => { - const [accessToken = '', idsParam = ''] = cacheKey.split('|'); +const fetchSavedFromWebRecipes = ( + cacheKey: string, + accessToken: string, + idsParam: string, +): Promise> => { + const cached = savedFromWebCache.get(cacheKey); + if (cached) { + return cached; + } + + const promise = (async (): Promise> => { try { const response = await fetch( `${SAVED_FROM_WEB_PROXY_PATH}?ids=${encodeURIComponent(idsParam)}`, @@ -87,17 +87,22 @@ const fetchSavedFromWebRecipes = makeMemoizedFunction( ); return new Set(); } - }, -); + })(); + + savedFromWebCache.set(cacheKey, promise); + return promise; +}; /** * Checks which of the given recipe ids are already saved to the reader's - * "Saved from web" list. See `fetchSavedFromWebRecipes` for the memoization + * "Saved from web" list. See `fetchSavedFromWebRecipes` for the caching * behaviour that keeps this to one network request per page. - * @param accessToken The reader's Okta access token + * @param userId The reader's user id (`idToken.claims.sub`), used only as the cache key + * @param accessToken The reader's Okta access token, used only to make the request * @param recipeIds The recipe ids to check (capped at `MaxSavedFromWebIdsPerRequest` on the Feast API; longer lists are truncated) */ export const getFeastSavedFromTheWebRecipes = ( + userId: string, accessToken: string, recipeIds: string[], ): Promise> => { @@ -116,9 +121,10 @@ export const getFeastSavedFromTheWebRecipes = ( ); } - return fetchSavedFromWebRecipes( - buildCacheKey(accessToken, cappedRecipeIds), - ); + const idsParam = Array.from(new Set(cappedRecipeIds)).sort().join(','); + const cacheKey = `${userId}:${idsParam}`; + + return fetchSavedFromWebRecipes(cacheKey, accessToken, idsParam); }; /** From 98d2e6a2b6326836cfcedcdde404557f9c26f879 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Thu, 23 Jul 2026 09:59:55 +0100 Subject: [PATCH 20/29] Update @guardian/identity-auth-frontend version to 0.0.0-canary-20260722105844 in package.json and pnpm-lock.yaml --- dotcom-rendering/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index 480a8b057a1..bbacf35592e 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -37,7 +37,7 @@ "@guardian/core-web-vitals": "7.0.0", "@guardian/eslint-config": "catalog:", "@guardian/identity-auth": "6.0.1", - "@guardian/identity-auth-frontend": "8.1.0", + "@guardian/identity-auth-frontend": "0.0.0-canary-20260722105844", "@guardian/libs": "32.0.0", "@guardian/ophan-tracker-js": "4.0.2", "@guardian/react-crossword": "19.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26cae81cd6e..59855c7e077 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,8 +342,8 @@ importers: specifier: 6.0.1 version: 6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3) '@guardian/identity-auth-frontend': - specifier: 8.1.0 - version: 8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3))(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3) + specifier: 0.0.0-canary-20260722105844 + version: 0.0.0-canary-20260722105844(@guardian/identity-auth@6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3))(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3) '@guardian/libs': specifier: 32.0.0 version: 32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3) @@ -2564,13 +2564,13 @@ packages: eslint-plugin-storybook: optional: true - '@guardian/identity-auth-frontend@8.1.0': - resolution: {integrity: sha512-2GzIsUBp8uiP+fRsKUpMrqJYSqokUCDo4q9WByi143CN0LRRWj2tVt23Y/+cZxWUuwDfRBxp1qbRnsy4QSMVLQ==} + '@guardian/identity-auth-frontend@0.0.0-canary-20260722105844': + resolution: {integrity: sha512-7DN0YSfffew3HBbfjB34PUb9lD1HDLBGWTt5+Lr16d8uKJ90rVKGU1E467UjFsb0gLsNBAs3nN2qMftOPcMGEA==} peerDependencies: - '@guardian/identity-auth': ^6.0.0 - '@guardian/libs': ^21.0.0 - tslib: ^2.6.2 - typescript: ~5.5.2 + '@guardian/identity-auth': ^18.0.0 + '@guardian/libs': ^33.0.0 + tslib: ^2.8.1 + typescript: ~6.0.3 peerDependenciesMeta: typescript: optional: true @@ -12020,7 +12020,7 @@ snapshots: - supports-color - typescript - '@guardian/identity-auth-frontend@8.1.0(@guardian/identity-auth@6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3))(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3)': + '@guardian/identity-auth-frontend@0.0.0-canary-20260722105844(@guardian/identity-auth@6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3))(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3)': dependencies: '@guardian/identity-auth': 6.0.1(@guardian/libs@32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3))(tslib@2.6.2)(typescript@6.0.3) '@guardian/libs': 32.0.0(@guardian/ophan-tracker-js@4.0.2)(tslib@2.6.2)(typescript@6.0.3) From 9e2368374d722c6c0f634164408dad2494ec8801 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:43:14 +0000 Subject: [PATCH 21/29] Resolve merge conflicts with main --- .../FeastContextualNudge.island.tsx | 73 --------- .../FeastContextualNudge.stories.tsx | 3 - dotcom-rendering/src/lib/ArticleRenderer.tsx | 11 -- .../src/lib/braze/BrazeBannersSystem.tsx | 146 ------------------ 4 files changed, 233 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 9dd7450ba0b..c89f90c6df1 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -7,21 +7,15 @@ import { } from '@guardian/source/foundations'; import { LinkButton } from '@guardian/source/react-components'; import { useEffect, useState } from 'react'; -<<<<<<< HEAD -======= import { submitComponentEvent } from '../client/ophan/ophan'; ->>>>>>> origin/main import { BrazeBannersSystemDisplay, BrazeBannersSystemPlacementId, isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; -<<<<<<< HEAD import { getFeastSavedFromTheWebRecipes } from '../lib/feast/savedFromWeb'; import { useAB } from '../lib/useAB'; import { useAuthStatus } from '../lib/useAuthStatus'; -======= ->>>>>>> origin/main import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; import type { RecipeBlockElement } from '../types/content'; @@ -172,7 +166,6 @@ type FeastContextualNudgeProps = { isDev: boolean; nudgeIndex: number; idApiUrl: string | undefined; -<<<<<<< HEAD /** * Every recipe id that will get a nudge on this page (at most 5). Shared * across all FeastContextualNudge instances so that whichever one @@ -181,8 +174,6 @@ type FeastContextualNudgeProps = { * its own recipe id separately. */ allNudgeRecipeIds: string[]; -======= ->>>>>>> origin/main }; /** @@ -205,7 +196,6 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, idApiUrl, -<<<<<<< HEAD allNudgeRecipeIds, }: FeastContextualNudgeProps) => { const abTests = useAB(); @@ -218,12 +208,6 @@ export const FeastContextualNudge = ({ // TEMP ONLY FOR DEVELOPMENT: force the nudge to render for testing purposes, even if the user is not in the AB test variant. const { darkModeAvailable, renderingTarget } = useConfig(); - -======= -}: FeastContextualNudgeProps) => { - const { darkModeAvailable, renderingTarget } = useConfig(); - ->>>>>>> origin/main const { braze } = useBraze(idApiUrl ?? '', renderingTarget); const [isStorybook, setIsStorybook] = useState(false); @@ -265,7 +249,6 @@ export const FeastContextualNudge = ({ } }, [feastId, title, pageId, isDev]); -<<<<<<< HEAD // Whether this recipe is already in the reader's "Saved from web" list. // `getFeastSavedFromTheWebRecipes` caches by user id + recipe ids, so no // matter how many FeastContextualNudge islands on this page call it as @@ -285,62 +268,6 @@ export const FeastContextualNudge = ({ }, [authStatus, feastId, allNudgeRecipeIds]); if (!isVariant) return null; -======= - // If idApiUrl is defined and Braze has a banner for this placement slot, - // render the Braze banner instead of the native nudge. - if (idApiUrl !== undefined) { - const placementId = - BrazeBannersSystemPlacementId[ - `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId - ]; - - // Guard against stale placements: if the last requestBannersRefresh - // was rate-limited AND this placement has suppressOnStale: true in - // PLACEMENT_SUPPRESS_ON_STALE, skip getBanner() and fall through to - // the native nudge below. - // - // Each FeastContextualNudge placement ID has its own entry in - // PLACEMENT_SUPPRESS_ON_STALE β€” change any individual one to `true` - // to suppress that specific nudge on a failed refresh. - const banner = !isPlacementStale(placementId) - ? (braze?.getBanner(placementId) ?? null) - : null; - - if (banner && braze) { - return ( -
- -
- ); - } - } ->>>>>>> origin/main // If idApiUrl is defined and Braze has a banner for this placement slot, // render the Braze banner instead of the native nudge. diff --git a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx index f7c91c35eb1..5f225b5e5d8 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.stories.tsx @@ -33,10 +33,7 @@ const meta = { isDev: true, nudgeIndex: 1, idApiUrl: undefined, -<<<<<<< HEAD allNudgeRecipeIds: [mockRecipe.id], -======= ->>>>>>> origin/main }, parameters: { chromatic: { viewports: [375, 740, 980] }, diff --git a/dotcom-rendering/src/lib/ArticleRenderer.tsx b/dotcom-rendering/src/lib/ArticleRenderer.tsx index 5a2908e4d49..7ef2c13fccd 100644 --- a/dotcom-rendering/src/lib/ArticleRenderer.tsx +++ b/dotcom-rendering/src/lib/ArticleRenderer.tsx @@ -168,7 +168,6 @@ export const ArticleRenderer = ({ const MAX_NUDGES = 5; const interval = Math.ceil(sections.length / MAX_NUDGES); -<<<<<<< HEAD const sectionsWithNudgeIndex = sections.map((section) => { const position = section.index + 1; // 1-based const nudgeIndex = @@ -192,13 +191,6 @@ export const ArticleRenderer = ({ .map(({ section }) => section.recipe!.id); for (const { section, nudgeIndex } of sectionsWithNudgeIndex) { -======= - for (const section of sections) { - const position = section.index + 1; // 1-based - const nudgeIndex = - position % interval === 0 ? position / interval : null; - ->>>>>>> origin/main result.push( {section.subheadingEl} @@ -215,10 +207,7 @@ export const ArticleRenderer = ({ recipeArticleTitle={section.recipeArticleTitle} nudgeIndex={nudgeIndex} idApiUrl={idApiUrl} -<<<<<<< HEAD allNudgeRecipeIds={allNudgeRecipeIds} -======= ->>>>>>> origin/main /> )} diff --git a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx index d7bde2a8baa..253fa892181 100644 --- a/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx +++ b/dotcom-rendering/src/lib/braze/BrazeBannersSystem.tsx @@ -112,118 +112,6 @@ export enum BrazeBannersSystemPlacementId { FeastContextualNudge3 = 'dotcom-rendering_feast-contextual-nudge-3', FeastContextualNudge4 = 'dotcom-rendering_feast-contextual-nudge-4', FeastContextualNudge5 = 'dotcom-rendering_feast-contextual-nudge-5', -<<<<<<< HEAD -} - -/** - * Maximum number of placements per refresh request, as per Braze documentation: - * https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh - */ -const BRAZE_MAX_PLACEMENTS_PER_REFRESH = 10; - -/** - * Maps each gu-island component name to the Braze Banner placement IDs it is - * responsible for rendering. When a gu-island element is absent from the - * server-rendered DOM, its placements are excluded from the refresh request, - * keeping requests within Braze's 10-placement cap and avoiding wasted - * rate-limit tokens on placements that cannot appear on the current page. - */ -const ISLAND_PLACEMENT_MAP: Record = { - StickyBottomBanner: [BrazeBannersSystemPlacementId.Banner], - SlotBodyEnd: [BrazeBannersSystemPlacementId.EndOfArticle], - FeastContextualNudge: [ - BrazeBannersSystemPlacementId.FeastContextualNudge1, - BrazeBannersSystemPlacementId.FeastContextualNudge2, - BrazeBannersSystemPlacementId.FeastContextualNudge3, - BrazeBannersSystemPlacementId.FeastContextualNudge4, - BrazeBannersSystemPlacementId.FeastContextualNudge5, - ], -}; - -/** - * Per-placement stale-suppression config. - * - * When requestBannersRefresh is rate-limited by Braze, getBanner() still - * returns the last-cached banner. For each placement ID listed here as `true`, - * DCR will actively hide the banner rather than risk showing outdated content. - * Placement IDs omitted from this map (or set to `false`) fall through to - * their component's own fallback behaviour. - * - * This is the single place to change suppression behaviour for any placement. - * Default for new placements: omit the entry (treated as `false`). - */ -const PLACEMENT_SUPPRESS_ON_STALE: Partial< - Record -> = { - // MRR placements: suppress on stale β€” avoid showing outdated campaigns - // that could mislead readers or contradict their current eligibility. - [BrazeBannersSystemPlacementId.Banner]: true, - [BrazeBannersSystemPlacementId.EndOfArticle]: true, - // Feast placements: not suppressed β€” FeastContextualNudge falls back to - // its native "Download the app" card when no Braze banner is available. - // Set any of these to `true` to suppress that specific nudge on stale. - [BrazeBannersSystemPlacementId.FeastContextualNudge1]: false, - [BrazeBannersSystemPlacementId.FeastContextualNudge2]: false, - [BrazeBannersSystemPlacementId.FeastContextualNudge3]: false, - [BrazeBannersSystemPlacementId.FeastContextualNudge4]: false, - [BrazeBannersSystemPlacementId.FeastContextualNudge5]: false, -}; - -/** - * Determines which Braze Banner placement IDs are needed on the current page - * by checking which gu-island elements were rendered into the DOM server-side. - * Only placements whose corresponding island is present are included. - */ -export function getPagePlacements(): BrazeBannersSystemPlacementId[] { - return Object.entries(ISLAND_PLACEMENT_MAP).flatMap(([islandName, ids]) => - document.querySelector(`gu-island[name="${islandName}"]`) ? ids : [], - ); -} - -/** - * Pre-computed set of placement IDs that opt-in to stale suppression. - * Derived once at module load time from PLACEMENT_SUPPRESS_ON_STALE entries - * where the value is true β€” avoids re-computing on every refreshBanners call. - */ -const STALE_SUPPRESSABLE_PLACEMENTS = new Set( - ( - Object.entries(PLACEMENT_SUPPRESS_ON_STALE) as Array< - [BrazeBannersSystemPlacementId, boolean] - > - ) - .filter(([, suppress]) => suppress) - .map(([id]) => id), -); - -/** - * Tracks placement IDs whose most-recent requestBannersRefresh call failed - * due to Braze's rate-limiter. When a placement is in this set, getBanner() - * may still return cached data from a previous session β€” but we actively - * suppress rendering to avoid showing outdated campaigns. - * - * Only placements with suppressOnStale: true in ISLAND_PLACEMENT_MAP are ever - * added here. Entries are removed when a subsequent refresh succeeds. - * - * This is module-level state. DCR has no SPA navigation β€” every page visit - * is a full reload β€” so this set always starts empty on each page. - */ -const stalePlacements = new Set(); - -/** - * Returns true if the given placement was marked stale because its last - * requestBannersRefresh call was rate-limited by Braze. When true, consumers - * should skip getBanner() and not render the Braze banner. - * - * Note: with the current config, FeastContextualNudge placements have - * suppressOnStale: false and will never be stale. This function is exported - * for forward-compatibility if that config changes in a future iteration. - * - * @param id The placement ID to check. - */ -export function isPlacementStale(id: BrazeBannersSystemPlacementId): boolean { - return stalePlacements.has(id); -======= ->>>>>>> origin/main } /** @@ -368,15 +256,6 @@ export function refreshBanners( braze: BrazeInstance, placements: BrazeBannersSystemPlacementId[], ): Promise { -<<<<<<< HEAD - brazeBannersSystemLogger.info( - `πŸ”„ Requesting ${placements.length} placement(s): ${placements.join(', ')}`, - ); - - if (placements.length > BRAZE_MAX_PLACEMENTS_PER_REFRESH) { - brazeBannersSystemLogger.warn( - `⚠️ ${placements.length} placements requested, but Braze only processes the first ${BRAZE_MAX_PLACEMENTS_PER_REFRESH} per refresh request. See https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh`, -======= const placementsToRequest = ALL_PLACEMENT_IDS.length <= BRAZE_MAX_PLACEMENTS_PER_REFRESH ? ALL_PLACEMENT_IDS @@ -389,7 +268,6 @@ export function refreshBanners( if (placementsToRequest.length > BRAZE_MAX_PLACEMENTS_PER_REFRESH) { brazeBannersSystemLogger.warn( `⚠️ ${placementsToRequest.length} placements requested, but Braze only processes the first ${BRAZE_MAX_PLACEMENTS_PER_REFRESH} per refresh request. See https://www.braze.com/docs/developer_guide/banners/placements/#requestBannersRefresh`, ->>>>>>> origin/main ); } @@ -410,19 +288,11 @@ export function refreshBanners( // Create the Braze Promise const brazeRequest = new Promise((resolve) => { braze.requestBannersRefresh( -<<<<<<< HEAD - placements, - () => { - // On success, lift stale status for any suppressable placements - // in this batch so a future re-refresh can restore them cleanly. - for (const id of placements) { -======= placementsToRequest, () => { // On success, lift stale status for any suppressable placements // in this batch so a future re-refresh can restore them cleanly. for (const id of placementsToRequest) { ->>>>>>> origin/main if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { stalePlacements.delete(id); } @@ -439,11 +309,7 @@ export function refreshBanners( // canShowBrazeBannersSystem (and direct consumers like // FeastContextualNudge) will not render the cached banner. const markedStale: BrazeBannersSystemPlacementId[] = []; -<<<<<<< HEAD - for (const id of placements) { -======= for (const id of placementsToRequest) { ->>>>>>> origin/main if (STALE_SUPPRESSABLE_PLACEMENTS.has(id)) { stalePlacements.add(id); markedStale.push(id); @@ -639,10 +505,7 @@ enum BrazeBannersSystemMessageType { NavigateToUrl = 'BRAZE_BANNERS_SYSTEM:NAVIGATE_TO_URL', DismissBanner = 'BRAZE_BANNERS_SYSTEM:DISMISS_BANNER', GetContext = 'BRAZE_BANNERS_SYSTEM:GET_CONTEXT', -<<<<<<< HEAD SaveFeastRecipeById = 'BRAZE_BANNERS_SYSTEM:SAVE_FEAST_RECIPE_BY_ID', -======= ->>>>>>> origin/main } /** @@ -1142,13 +1005,10 @@ export const BrazeBannersSystemDisplay = ({ | { type: BrazeBannersSystemMessageType.GetContext; } -<<<<<<< HEAD | { type: BrazeBannersSystemMessageType.SaveFeastRecipeById; feastRecipeId?: string; } -======= ->>>>>>> origin/main >, ) => { if ( @@ -1299,7 +1159,6 @@ export const BrazeBannersSystemDisplay = ({ }, ); break; -<<<<<<< HEAD case BrazeBannersSystemMessageType.SaveFeastRecipeById: { meta.braze.logBannerClick( meta.banner, @@ -1328,8 +1187,6 @@ export const BrazeBannersSystemDisplay = ({ } break; } -======= ->>>>>>> origin/main } }; @@ -1348,10 +1205,7 @@ export const BrazeBannersSystemDisplay = ({ logNavigateToUrlClick, postMessageToBrazeBanner, context, -<<<<<<< HEAD saveFeastRecipeById, -======= ->>>>>>> origin/main ]); // Log Impressions when the banner is seen, using the hasBeenSeen value from the useIsInView hook From 568fd9f843e9950ddad21c989ed11af8e8cad4b7 Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 28 Jul 2026 14:50:46 +0100 Subject: [PATCH 22/29] Remove A/B testing logic from FeastContextualNudge component --- .../src/components/FeastContextualNudge.island.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index c89f90c6df1..c5fe72888c2 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -14,7 +14,6 @@ import { isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; import { getFeastSavedFromTheWebRecipes } from '../lib/feast/savedFromWeb'; -import { useAB } from '../lib/useAB'; import { useAuthStatus } from '../lib/useAuthStatus'; import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; @@ -198,15 +197,6 @@ export const FeastContextualNudge = ({ idApiUrl, allNudgeRecipeIds, }: FeastContextualNudgeProps) => { - const abTests = useAB(); - let isVariant = - abTests?.isUserInTestGroup('feast-recipe-nudge-v2', 'variant-1') ?? - false; - - // TEMP ONLY FOR DEVELOPMENT: force the nudge to render for testing purposes, even if the user is not in the AB test variant. - isVariant = true; - // TEMP ONLY FOR DEVELOPMENT: force the nudge to render for testing purposes, even if the user is not in the AB test variant. - const { darkModeAvailable, renderingTarget } = useConfig(); const { braze } = useBraze(idApiUrl ?? '', renderingTarget); @@ -267,8 +257,6 @@ export const FeastContextualNudge = ({ }); }, [authStatus, feastId, allNudgeRecipeIds]); - if (!isVariant) return null; - // If idApiUrl is defined and Braze has a banner for this placement slot, // render the Braze banner instead of the native nudge. if (idApiUrl !== undefined) { From 6dab519a7e4f4c41b8ee1b3397a358c30917d67b Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Tue, 28 Jul 2026 15:22:26 +0100 Subject: [PATCH 23/29] Add timeout for recipe saving status and improve layout consistency --- .../FeastContextualNudge.island.tsx | 90 ++++++++++++++++--- 1 file changed, 76 insertions(+), 14 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index c5fe72888c2..c4c346eb140 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -118,6 +118,27 @@ const nudgeMinHeightStyles = css` } `; +/** + * Extra vertical margin applied around the reserved-height box, matching + * `showcaseCardStyles`' own margin so the placeholder shown while the + * "Saved from web" status is loading (see `isRecipeSaved` below) takes up + * exactly the same space as whichever of the Braze banner or native card + * replaces it, keeping the CLS mitigation consistent across all three + * states. + */ +const nudgeSpacingStyles = css` + margin: ${space[2]}px 0; +`; + +/** + * Upper bound on how long to wait for `getFeastSavedFromTheWebRecipes` (see + * below) before giving up and treating the recipe as not-saved. Prevents an + * unusually slow/hanging request from blocking the nudge from rendering at + * all. Mirrors the timeout pattern used for `fetchEmail` in + * `BrazeBannersSystem.tsx`. + */ +const SAVED_FROM_WEB_TIMEOUT_MS = 2_000; + // ── Card styles ─────────────────────────────────────────────────────────────── const showcaseCardStyles = css` @@ -244,19 +265,65 @@ export const FeastContextualNudge = ({ // matter how many FeastContextualNudge islands on this page call it as // they hydrate (each is deferred `until: 'visible'`), only one network // request for the batch of recipe ids on this page is ever made. + // + // `undefined` specifically means "not yet known" (auth status still + // pending, or the saved-from-web request is still in flight) as opposed + // to `false`, which means "known to not be saved". The Braze banner + // iframe reads `isRecipeSaved` from `context` only once, when it asks for + // context on load (see `GetContext` in `BrazeBannersSystem.tsx`) β€” so the + // banner must not be rendered until the real value is known, otherwise it + // would be permanently stuck showing the wrong saved state. See the + // render gate below, which holds off rendering anything (Braze banner + // *or* native fallback) until this resolves. const authStatus = useAuthStatus(); - const [isRecipeSaved, setIsRecipeSaved] = useState(false); + const [isRecipeSaved, setIsRecipeSaved] = useState( + undefined, + ); useEffect(() => { - if (authStatus.kind !== 'SignedIn') return; - void getFeastSavedFromTheWebRecipes( - authStatus.idToken.claims.sub, - authStatus.accessToken.accessToken, - allNudgeRecipeIds, - ).then((savedRecipeIds) => { - setIsRecipeSaved(savedRecipeIds.has(feastId)); + if (authStatus.kind === 'Pending') return; + if (authStatus.kind !== 'SignedIn') { + setIsRecipeSaved(false); + return; + } + + let cancelled = false; + const timeout = new Promise>((resolve) => { + setTimeout(() => resolve(new Set()), SAVED_FROM_WEB_TIMEOUT_MS); + }); + void Promise.race([ + getFeastSavedFromTheWebRecipes( + authStatus.idToken.claims.sub, + authStatus.accessToken.accessToken, + allNudgeRecipeIds, + ), + timeout, + ]).then((savedRecipeIds) => { + if (!cancelled) setIsRecipeSaved(savedRecipeIds.has(feastId)); }); + return () => { + cancelled = true; + }; }, [authStatus, feastId, allNudgeRecipeIds]); + // Only the native fallback renders `isRecipeSaved`-free, so it's safe to + // show straight away when there's no Braze placement to wait on. When a + // Braze placement is possible, hold off on rendering anything until the + // saved-from-web status is known, so the native card never flashes before + // the Braze banner is ready to show with the correct context, and so the + // banner is never shown with a stale/incorrect `isRecipeSaved`. The + // reserved height/margin (`nudgeMinHeightStyles` + `nudgeSpacingStyles`) + // matches the Braze/native cards below, so this causes no layout shift + // once real content replaces it. + if (idApiUrl !== undefined && isRecipeSaved === undefined) { + return ( +