diff --git a/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx
new file mode 100644
index 00000000000..c214f5ce47f
--- /dev/null
+++ b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.test.tsx
@@ -0,0 +1,146 @@
+import { use, StrictMode } from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { AccountContext } from '#app/contexts/AccountContext';
+import { RequestContext } from '#app/contexts/RequestContext';
+import uasApiRequest from '#app/lib/uasApi';
+import uasKeys from '#app/lib/uasApi/queryKeys';
+import parseRoute from '#app/routes/utils/parseRoute';
+import ArticleNotFoundUASCleanup from './ArticleNotFoundUASCleanup';
+
+jest.mock('#app/lib/uasApi');
+jest.mock('#app/routes/utils/parseRoute');
+jest.mock('react', () => ({
+ ...jest.requireActual('react'),
+ use: jest.fn(),
+}));
+
+const mockGetQueriesData = jest.fn();
+const mockRemoveQueries = jest.fn();
+const mockQueryClient = {
+ getQueriesData: mockGetQueriesData,
+ removeQueries: mockRemoveQueries,
+};
+
+jest.mock('@tanstack/react-query', () => ({
+ ...jest.requireActual('@tanstack/react-query'),
+ useQueryClient: () => mockQueryClient,
+}));
+
+const mockUse = use as jest.Mock;
+const mockUasApiRequest = uasApiRequest as jest.Mock;
+const mockParseRoute = parseRoute as jest.Mock;
+
+const HASHED_USER_ID = 'user-123';
+const ARTICLE_ID = 'article-123';
+
+describe('ArticleNotFoundUASCleanup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUse.mockImplementation(context => {
+ if (context === AccountContext)
+ return { hashedUserId: HASHED_USER_ID, isRefreshAvailable: true };
+ if (context === RequestContext)
+ return { pathname: '/news/articles/article-123' };
+ return {};
+ });
+
+ mockParseRoute.mockReturnValue({ assetId: ARTICLE_ID });
+ mockGetQueriesData.mockReturnValue([]);
+ mockUasApiRequest.mockResolvedValue({ ok: true, status: 200 });
+ });
+
+ it('renders null', () => {
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('calls getQueriesData with the queryKey from uasKeys.favouritesList(hashedUserId)', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockGetQueriesData).toHaveBeenCalledWith({
+ queryKey: uasKeys.favouritesList(HASHED_USER_ID),
+ });
+ });
+ });
+
+ it('does not call uasApiRequest when articleId is not present in any cached favourites page', async () => {
+ mockGetQueriesData.mockReturnValue([
+ [
+ uasKeys.favouritesPage(HASHED_USER_ID, 0),
+ { savedArticles: [{ id: 'some-other-article' }] },
+ ],
+ ]);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockGetQueriesData).toHaveBeenCalled();
+ });
+ expect(mockUasApiRequest).not.toHaveBeenCalled();
+ });
+
+ it('proceeds with deletion when articleId is found on one of several cached pages', async () => {
+ mockGetQueriesData.mockReturnValue([
+ [
+ uasKeys.favouritesPage(HASHED_USER_ID, 0),
+ { savedArticles: [{ id: 'some-other-article' }] },
+ ],
+ [
+ uasKeys.favouritesPage(HASHED_USER_ID, 1),
+ { savedArticles: [{ id: ARTICLE_ID }] },
+ ],
+ ]);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockUasApiRequest).toHaveBeenCalledWith('DELETE', 'favourites', {
+ globalId: `urn:bbc:world-service-news:article:${ARTICLE_ID}`,
+ isRefreshAvailable: true,
+ });
+ });
+ });
+
+ it('calls queryClient.removeQueries with the favouritesList queryKey after a successful delete', async () => {
+ mockGetQueriesData.mockReturnValue([
+ [
+ uasKeys.favouritesPage(HASHED_USER_ID, 0),
+ { savedArticles: [{ id: ARTICLE_ID }] },
+ ],
+ ]);
+
+ render();
+
+ await waitFor(() => {
+ expect(mockRemoveQueries).toHaveBeenCalledWith({
+ queryKey: uasKeys.favouritesList(HASHED_USER_ID),
+ });
+ });
+ });
+
+ it('only calls uasApiRequest once even if the effect re-runs with the same hashedUserId/articleId', async () => {
+ mockGetQueriesData.mockReturnValue([
+ [
+ uasKeys.favouritesPage(HASHED_USER_ID, 0),
+ { savedArticles: [{ id: ARTICLE_ID }] },
+ ],
+ ]);
+
+ // StrictMode intentionally mounts, cleans up, and remounts on initial
+ // render, invoking effects twice with identical deps. This is what
+ // hasAttemptedDeletion.current guards against in the component.
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockRemoveQueries).toHaveBeenCalledTimes(1);
+ });
+ expect(mockUasApiRequest).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx
new file mode 100644
index 00000000000..ad3b25c0ed2
--- /dev/null
+++ b/src/app/components/ArticleNotFoundUASCleanup/ArticleNotFoundUASCleanup.tsx
@@ -0,0 +1,69 @@
+import { use, useEffect, useRef } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { AccountContext } from '#app/contexts/AccountContext';
+import { RequestContext } from '#app/contexts/RequestContext';
+import parseRoute from '#app/routes/utils/parseRoute';
+import uasApiRequest from '#app/lib/uasApi';
+import {
+ buildGlobalId,
+ FAVOURITES_CONFIG,
+ SavedArticle,
+} from '#app/lib/uasApi/uasUtility';
+import uasKeys from '#app/lib/uasApi/queryKeys';
+import { UAS_API_ERROR } from '#app/lib/logger.const';
+import nodeLogger from '#lib/logger.node';
+
+const logger = nodeLogger(__filename);
+
+/**
+ * Client-side only component that removes a 404'd article from UAS favourites.
+ * Rendered only after hydration (ssr: false in index.tsx) to avoid SSR errors
+ * from useQueryClient. Mount conditions (personalization, pageType, errorCode)
+ * are checked in ErrorPage before rendering this component.
+ */
+const ArticleNotFoundUASCleanup = () => {
+ const { hashedUserId = '', isRefreshAvailable } = use(AccountContext);
+ const { pathname } = use(RequestContext);
+ const queryClient = useQueryClient();
+ const { assetId: articleId } = parseRoute(pathname);
+ const hasAttemptedDeletion = useRef(false);
+
+ useEffect(() => {
+ if (!hashedUserId || !articleId || hasAttemptedDeletion.current) return;
+
+ const removeDeletedArticleFromUAS = async () => {
+ try {
+ hasAttemptedDeletion.current = true;
+
+ const cachedPages = queryClient.getQueriesData<{
+ savedArticles: SavedArticle[];
+ }>({ queryKey: uasKeys.favouritesList(hashedUserId) });
+
+ const isInFavouritesList = cachedPages.some(([, data]) =>
+ data?.savedArticles?.some(article => article.id === articleId),
+ );
+
+ if (!isInFavouritesList) return;
+
+ const globalId = buildGlobalId(articleId);
+ await uasApiRequest('DELETE', FAVOURITES_CONFIG.activityType, {
+ globalId,
+ isRefreshAvailable,
+ });
+
+ queryClient.removeQueries({
+ queryKey: uasKeys.favouritesList(hashedUserId),
+ });
+ } catch {
+ logger.error(UAS_API_ERROR, {
+ error: 'Failed to remove deleted article from UAS favourites',
+ });
+ }
+ };
+
+ removeDeletedArticleFromUAS();
+ }, [queryClient, hashedUserId, articleId, isRefreshAvailable]);
+ return null;
+};
+
+export default ArticleNotFoundUASCleanup;
diff --git a/src/app/components/ArticleNotFoundUASCleanup/index.tsx b/src/app/components/ArticleNotFoundUASCleanup/index.tsx
new file mode 100644
index 00000000000..e082f7290b6
--- /dev/null
+++ b/src/app/components/ArticleNotFoundUASCleanup/index.tsx
@@ -0,0 +1,10 @@
+import dynamic from 'next/dynamic';
+
+// TanStack Query must not be bundled server-side — ssr: false ensures the
+// client component (and its useQueryClient call) only loads after hydration.
+const ArticleNotFoundUASCleanup = dynamic(
+ () => import('./ArticleNotFoundUASCleanup'),
+ { ssr: false },
+);
+
+export default ArticleNotFoundUASCleanup;
diff --git a/src/app/pages/ErrorPage/ErrorPage.jsx b/src/app/pages/ErrorPage/ErrorPage.jsx
index c5582377263..c107b199c75 100644
--- a/src/app/pages/ErrorPage/ErrorPage.jsx
+++ b/src/app/pages/ErrorPage/ErrorPage.jsx
@@ -2,8 +2,12 @@ import { use } from 'react';
import { Helmet } from 'react-helmet';
import ErrorMain from '#components/ErrorMain';
import { useTheme } from '@emotion/react';
-import { ServiceContext } from '../../contexts/ServiceContext';
-
+import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes';
+import ArticleNotFoundUASCleanup from '#app/components/ArticleNotFoundUASCleanup';
+import { NOT_FOUND } from '#lib/statusCodes.const';
+import { RequestContext } from '#app/contexts/RequestContext';
+import { ServiceContext } from '#app/contexts/ServiceContext';
+import { AccountContext } from '#app/contexts/AccountContext';
/*
* MVP Metadata for the error
* This will be refactored out in https://github.com/bbc/simorgh/issues/1350
@@ -34,14 +38,22 @@ const ErrorMetadata = ({ dir, lang, messaging, brandName, themeColor }) => {
const ErrorPage = ({ errorCode }) => {
const { brandName, dir, lang, translations } = use(ServiceContext);
+ const { pageType } = use(RequestContext);
+ const { isPersonalizationEnabled } = use(AccountContext);
const messaging = translations.error[errorCode] || translations.error[500];
const {
palette: { BRAND_BACKGROUND },
} = useTheme();
+ const shouldRenderArticleCleanup =
+ isPersonalizationEnabled &&
+ errorCode === NOT_FOUND &&
+ (pageType === ARTICLE_PAGE || pageType === MEDIA_ARTICLE_PAGE);
+
return (
<>
+ {shouldRenderArticleCleanup && }
({
+ __esModule: true,
+ default: () => ,
+}));
+
+const personalizationToggle = {
+ uasPersonalization: { enabled: true, value: 'hindi' },
+};
+
+const personalizedRenderOptions = {
+ service: 'hindi',
+ toggles: personalizationToggle,
+ idctaConfig: { ...mockIdctaConfig, initialIsSignedIn: true },
+};
describe('ErrorPage', () => {
it('should correctly render for 404', () => {
@@ -36,4 +56,38 @@ describe('ErrorPage', () => {
});
expect(container).toMatchSnapshot();
});
+
+ it('renders ArticleNotFoundUASCleanup when personalization is enabled, errorCode is 404 and pageType is an article page', () => {
+ render(, {
+ ...personalizedRenderOptions,
+ pageType: ARTICLE_PAGE,
+ });
+
+ expect(
+ screen.getByTestId('article-not-found-uas-cleanup'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders ArticleNotFoundUASCleanup when personalization is enabled, errorCode is 404 and pageType is a media article page', () => {
+ render(, {
+ ...personalizedRenderOptions,
+ pageType: MEDIA_ARTICLE_PAGE,
+ });
+
+ expect(
+ screen.getByTestId('article-not-found-uas-cleanup'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not render ArticleNotFoundUASCleanup when user is not signed in', () => {
+ render(, {
+ ...personalizedRenderOptions,
+ idctaConfig: { ...mockIdctaConfig, initialIsSignedIn: false },
+ pageType: ARTICLE_PAGE,
+ });
+
+ expect(
+ screen.queryByTestId('article-not-found-uas-cleanup'),
+ ).not.toBeInTheDocument();
+ });
});