Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -70,9 +73,11 @@ describe('FeastContextualNudge Braze fallback', () => {
braze,
brazeCards: undefined,
brazeMessages: undefined,
isLoading: false,
});
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', () => {
Expand Down Expand Up @@ -114,4 +119,57 @@ 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();
});

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();
});
});
151 changes: 92 additions & 59 deletions dotcom-rendering/src/components/FeastContextualNudge.island.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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<boolean | undefined>(
undefined,
Expand Down Expand Up @@ -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 (
<div
data-component="feast-contextual-nudge"
Expand All @@ -326,53 +367,45 @@ export const FeastContextualNudge = ({

// 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 (idApiUrl !== undefined && banner && braze) {
return (
<div
aria-description={`Open the recipe ${title} in the Feast app`}
data-component="feast-contextual-nudge"
css={[nudgeMinHeightStyles, nudgeSpacingStyles]}
>
<BrazeBannersSystemDisplay
meta={{
id: `feast-contextual-nudge-${nudgeIndex}`,
braze,
banner,
}}
idApiUrl={idApiUrl}
stage={stage}
context={{
recipe,
recipeArticleTitle,
pageId,
isDev,
nudgeIndex,
darkMode: darkModeAvailable,
adjustToken: getAdjustToken(stage),
isRecipeSaved,
}}
/>
</div>
);
}

if (banner && braze) {
return (
<div
aria-description={`Open the recipe ${title} in the Feast app`}
data-component="feast-contextual-nudge"
css={[nudgeMinHeightStyles, nudgeSpacingStyles]}
>
<BrazeBannersSystemDisplay
meta={{
id: `feast-contextual-nudge-${nudgeIndex}`,
braze,
banner,
}}
idApiUrl={idApiUrl}
stage={stage}
context={{
recipe,
recipeArticleTitle,
pageId,
isDev,
nudgeIndex,
darkMode: darkModeAvailable,
adjustToken: getAdjustToken(stage),
isRecipeSaved,
}}
/>
</div>
);
}
// 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 (
Expand Down
17 changes: 17 additions & 0 deletions dotcom-rendering/src/lib/useBraze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,17 +48,23 @@ 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,
};
}

return {
brazeMessages: data?.brazeMessages,
brazeCards: data?.brazeCards,
braze: data?.braze ? data?.braze : null,
isLoading,
};
};
Loading