diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index c45dc366190..e85954f5110 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/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index 7e77b4b5cfe..c4c346eb140 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -13,6 +13,8 @@ import { BrazeBannersSystemPlacementId, isPlacementStale, } from '../lib/braze/BrazeBannersSystem'; +import { getFeastSavedFromTheWebRecipes } from '../lib/feast/savedFromWeb'; +import { useAuthStatus } from '../lib/useAuthStatus'; import { useBraze } from '../lib/useBraze'; import type { StageType } from '../types/config'; import type { RecipeBlockElement } from '../types/content'; @@ -116,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` @@ -163,6 +186,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[]; }; /** @@ -185,9 +216,9 @@ export const FeastContextualNudge = ({ isDev, nudgeIndex, idApiUrl, + allNudgeRecipeIds, }: FeastContextualNudgeProps) => { const { darkModeAvailable, renderingTarget } = useConfig(); - const { braze } = useBraze(idApiUrl ?? '', renderingTarget); const [isStorybook, setIsStorybook] = useState(false); @@ -229,6 +260,70 @@ export const FeastContextualNudge = ({ } }, [feastId, title, pageId, isDev]); + // 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 + // 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( + undefined, + ); + useEffect(() => { + 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 ( +