From 6bcb7203362ace3ab1cfe05deebc5786c5bb356c Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Thu, 10 Sep 2026 11:04:55 +0100 Subject: [PATCH 1/2] Hide native Feast recipe nudge from signed-in users with no Braze banner Previously, a signed-in reader who wasn't targeted by any Braze Canvas for a FeastContextualNudge placement would still see the generic native "Download the app" fallback card. Since Braze targeting is the mechanism for deciding who should see this nudge, a signed-in reader with no eligible banner should see nothing rather than a non-personalised fallback. Signed-out/pending readers aren't Braze-targetable at all, so they still see the native fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FeastContextualNudge.island.test.tsx | 27 +++++++++++++++++-- .../FeastContextualNudge.island.tsx | 11 ++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx index 2bc653d9093..9159f66f02c 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx @@ -1,7 +1,9 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { isPlacementStale } from '../lib/braze/BrazeBannersSystem'; import type { BrazeInstance } from '../lib/braze/initialiseBraze'; +import * as savedFromWeb from '../lib/feast/savedFromWeb'; import { useAB } from '../lib/useAB'; +import { useAuthStatus } from '../lib/useAuthStatus'; import { useBraze } from '../lib/useBraze'; import type { RecipeBlockElement } from '../types/content'; import { ConfigProvider } from './ConfigContext'; @@ -10,8 +12,9 @@ import { FeastContextualNudge } from './FeastContextualNudge.island'; jest.mock('../lib/useAB'); jest.mock('../lib/useBraze'); jest.mock('../lib/useAuthStatus', () => ({ - useAuthStatus: jest.fn().mockReturnValue({ kind: 'SignedOut' }), + useAuthStatus: jest.fn(), })); +jest.mock('../lib/feast/savedFromWeb'); jest.mock('../lib/braze/BrazeBannersSystem', () => ({ BrazeBannersSystemPlacementId: { FeastContextualNudge1: 'dotcom-rendering_feast-contextual-nudge-1', @@ -73,6 +76,7 @@ describe('FeastContextualNudge Braze fallback', () => { }); jest.mocked(isPlacementStale).mockReturnValue(false); jest.mocked(braze.getBanner).mockReset(); + jest.mocked(useAuthStatus).mockReturnValue({ kind: 'SignedOut' }); }); it('renders the Braze banner for a fresh eligible placement', () => { @@ -114,4 +118,23 @@ describe('FeastContextualNudge Braze fallback', () => { expect(screen.getByText('Download the app')).toBeInTheDocument(); expect(braze.getBanner).not.toHaveBeenCalled(); }); + + it('renders nothing for a signed-in reader with no Braze banner eligible', async () => { + jest.mocked(useAuthStatus).mockReturnValue({ + kind: 'SignedIn', + accessToken: { accessToken: 'token' } as never, + idToken: { claims: { sub: 'user-id' } } as never, + }); + jest.mocked( + savedFromWeb.getFeastSavedFromTheWebRecipes, + ).mockResolvedValue(new Set()); + jest.mocked(braze.getBanner).mockReturnValue(null); + + const { container } = renderNudge('https://id.test'); + + await waitFor(() => { + expect(container).toBeEmptyDOMElement(); + }); + expect(screen.queryByText('Download the app')).not.toBeInTheDocument(); + }); }); diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index c4c346eb140..cd0ff9f39c1 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -373,6 +373,17 @@ export const FeastContextualNudge = ({ ); } + + // A signed-in reader with no Braze banner for this placement is (by + // definition) not part of any Canvas targeting this nudge. Rather + // than falling back to the generic native "Download the app" card — + // which isn't personalised and could be shown to a reader who has + // been deliberately excluded from Feast messaging — show nothing. + // Signed-out/pending readers aren't Braze-targetable at all, so they + // still fall through to the native nudge below. + if (authStatus.kind === 'SignedIn') { + return null; + } } return ( From ac5e4f448431e2d1aadc88277a084b2ee60f520a Mon Sep 17 00:00:00 2001 From: andresilva-guardian Date: Thu, 10 Sep 2026 11:28:07 +0100 Subject: [PATCH 2/2] Avoid layout shift when hiding the nudge for signed-in non-targeted readers Deciding whether to hide the nudge for a signed-in reader who isn't targeted by any Braze Canvas previously waited on the same isRecipeSaved (saved-from-web) fetch used for the Braze banner context. That fetch is unrelated to whether a banner exists at all, so the reserved-height placeholder could sit on screen for up to 2s and then collapse to nothing once resolved, causing a visible layout shift. Base the 'no banner' decision on Braze's own load state instead (exposed as isLoading from useBraze, derived from SWR's data/error since this project's SWR version predates the built-in isLoading flag). This lets the decision resolve as soon as Braze itself is ready, independently of the saved-from-web fetch, shortening the window during which the reserved placeholder is visible before collapsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FeastContextualNudge.island.test.tsx | 35 ++++ .../FeastContextualNudge.island.tsx | 162 ++++++++++-------- dotcom-rendering/src/lib/useBraze.ts | 17 ++ 3 files changed, 144 insertions(+), 70 deletions(-) diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx index 9159f66f02c..199e22f6593 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.test.tsx @@ -73,6 +73,7 @@ describe('FeastContextualNudge Braze fallback', () => { braze, brazeCards: undefined, brazeMessages: undefined, + isLoading: false, }); jest.mocked(isPlacementStale).mockReturnValue(false); jest.mocked(braze.getBanner).mockReset(); @@ -137,4 +138,38 @@ describe('FeastContextualNudge Braze fallback', () => { }); expect(screen.queryByText('Download the app')).not.toBeInTheDocument(); }); + + it('reserves layout space while Braze is still loading, for a signed-in reader', () => { + jest.mocked(useAuthStatus).mockReturnValue({ + kind: 'SignedIn', + accessToken: { accessToken: 'token' } as never, + idToken: { claims: { sub: 'user-id' } } as never, + }); + jest.mocked( + savedFromWeb.getFeastSavedFromTheWebRecipes, + ).mockReturnValue( + new Promise(() => { + // Deliberately never resolves: this test only cares about the + // Braze-loading gate, so the saved-from-web fetch is left + // permanently in flight to avoid an unrelated act() warning + // from its resolution racing the test's assertions. + }), + ); + jest.mocked(useBraze).mockReturnValue({ + braze: null, + brazeCards: undefined, + brazeMessages: undefined, + isLoading: true, + }); + + const { container } = renderNudge('https://id.test'); + + expect( + container.querySelector( + '[data-component="feast-contextual-nudge"]', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Download the app')).not.toBeInTheDocument(); + expect(braze.getBanner).not.toHaveBeenCalled(); + }); }); diff --git a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx index cd0ff9f39c1..36b4122890a 100644 --- a/dotcom-rendering/src/components/FeastContextualNudge.island.tsx +++ b/dotcom-rendering/src/components/FeastContextualNudge.island.tsx @@ -219,7 +219,10 @@ export const FeastContextualNudge = ({ allNudgeRecipeIds, }: FeastContextualNudgeProps) => { const { darkModeAvailable, renderingTarget } = useConfig(); - const { braze } = useBraze(idApiUrl ?? '', renderingTarget); + const { braze, isLoading: isBrazeLoading } = useBraze( + idApiUrl ?? '', + renderingTarget, + ); const [isStorybook, setIsStorybook] = useState(false); useEffect(() => { @@ -273,8 +276,8 @@ export const FeastContextualNudge = ({ // 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. + // render gate below, which holds off rendering the banner until this + // resolves. const authStatus = useAuthStatus(); const [isRecipeSaved, setIsRecipeSaved] = useState( undefined, @@ -305,16 +308,54 @@ export const FeastContextualNudge = ({ }; }, [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) { + // Whether Braze has a banner for this placement slot. This only depends + // on `braze`/`isPlacementStale`, not on `isRecipeSaved`, so it can be + // worked out independently of (and typically well before) the + // saved-from-web fetch above resolves. + const placementId = + idApiUrl !== undefined + ? BrazeBannersSystemPlacementId[ + `FeastContextualNudge${nudgeIndex}` as keyof typeof BrazeBannersSystemPlacementId + ] + : undefined; + + // 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 treat this as "no + // banner", falling 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 = + placementId !== undefined && !isPlacementStale(placementId) + ? (braze?.getBanner(placementId) ?? null) + : null; + + // Hold off rendering anything until: + // - auth status is known (not `Pending`) — otherwise a signed-in + // reader with no banner could briefly flash the native fallback + // before we find out they're signed in and should see nothing; and + // - Braze has finished loading (`isBrazeLoading` is false) — otherwise + // "no banner yet" (still loading) could be mistaken for "no banner" + // (Braze has decided this reader isn't targeted); and + // - if there *is* a banner, `isRecipeSaved` is known — so the banner is + // never shown with a stale/incorrect saved state. + // + // The reserved height/margin (`nudgeMinHeightStyles` + + // `nudgeSpacingStyles`) matches the Braze/native cards below, so this + // causes no layout shift once real content replaces it. Note this does + // *not* wait on the saved-from-web fetch when there's no banner to show + // (signed-in + no banner resolves to nothing as soon as Braze is ready, + // without waiting up to `SAVED_FROM_WEB_TIMEOUT_MS` for a fetch whose + // result wouldn't be used anyway), which keeps the reserved box on + // screen for as short a time as possible before it collapses away. + if ( + idApiUrl !== undefined && + (authStatus.kind === 'Pending' || + isBrazeLoading || + (banner !== null && isRecipeSaved === undefined)) + ) { return (
- -
- ); - } + if (idApiUrl !== undefined && banner && braze) { + return ( +
+ +
+ ); + } - // A signed-in reader with no Braze banner for this placement is (by - // definition) not part of any Canvas targeting this nudge. Rather - // than falling back to the generic native "Download the app" card — - // which isn't personalised and could be shown to a reader who has - // been deliberately excluded from Feast messaging — show nothing. - // Signed-out/pending readers aren't Braze-targetable at all, so they - // still fall through to the native nudge below. - if (authStatus.kind === 'SignedIn') { - return null; - } + // A signed-in reader with no Braze banner for this placement is (by + // definition) not part of any Canvas targeting this nudge. Rather than + // falling back to the generic native "Download the app" card — which + // isn't personalised and could be shown to a reader who has been + // deliberately excluded from Feast messaging — show nothing. + // Signed-out readers aren't Braze-targetable at all, so they still fall + // through to the native nudge below. + if (idApiUrl !== undefined && authStatus.kind === 'SignedIn') { + return null; } return ( diff --git a/dotcom-rendering/src/lib/useBraze.ts b/dotcom-rendering/src/lib/useBraze.ts index ebd36e8035e..fa1c2620800 100644 --- a/dotcom-rendering/src/lib/useBraze.ts +++ b/dotcom-rendering/src/lib/useBraze.ts @@ -28,6 +28,17 @@ export const useBraze = ( brazeMessages: BrazeMessagesInterface | undefined; brazeCards: BrazeCardsInterface | undefined; braze: BrazeInstance | null; + /** + * Whether the underlying `buildBrazeMessaging` fetch (SDK init + the + * Banners System `requestBannersRefresh` call — see + * `buildBrazeMessaging.ts`) is still in flight. `false` once it has + * settled, whether that's with a usable `braze` instance or with + * `error`/a null `braze`. Consumers that need to know whether Braze has + * had a real chance to return banner data (as opposed to just not having + * one) — e.g. to avoid mistaking "not loaded yet" for "no banner" — + * should check this rather than relying on `braze` being non-null. + */ + isLoading: boolean; } => { const authStatus = useAuthStatus(); const isSignedIn = authStatus.kind === 'SignedIn'; @@ -37,11 +48,16 @@ export const useBraze = ( () => buildBrazeMessaging(idApiUrl, isSignedIn, renderingTarget), ); + // SWR 1.x doesn't expose an `isLoading` flag directly, so derive it: once + // the fetch settles, either `data` or `error` is populated. + const isLoading = data === undefined && error === undefined; + if (error) { return { brazeMessages: new NullBrazeMessages(), brazeCards: new NullBrazeCards(), braze: null, + isLoading, }; } @@ -49,5 +65,6 @@ export const useBraze = ( brazeMessages: data?.brazeMessages, brazeCards: data?.brazeCards, braze: data?.braze ? data?.braze : null, + isLoading, }; };