From b5d912536cdce28d8878b564c7069631eda8bef0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 25 Aug 2026 15:17:22 +1000 Subject: [PATCH] PM-5935: Render a not-found page instead of a blank page for unowned paths What was broken The Topcoder opportunities page showed nothing at all. On https://topcoder-dev.com/opportunities the Platform UI shell loaded (header, footer, application container) but the content area stayed empty, and the browser console reported `No routes matched location "/opportunities"`. Root cause The website CloudFront viewer-request function now hands `/opportunities` and `/opportunities/*` on the apex host to the Platform UI origin (topcoder-website commit "Route opportunities to platform UI"), and the new Opportunities application is still on the unmerged `opportunities-v6` branch (platform-ui PR #2147). Platform UI therefore serves the request but has no route registered for that path. `PlatformRouter` renders one `` per entry in `platformRoutes`, none of which matched, so react-router rendered nothing and the user saw a blank page. Platform UI had no fallback route at all, so the same blank page appeared for any path routed to it that it does not own. What was changed Added a `NotFoundPage` under `src/apps/platform/src/routes/not-found` and registered it in `platformRoutes` as a `route: '*'` catch-all placed last in the list. React-router ranks the `*` path lowest, so the fallback cannot shadow any route declared above it, and `routeGetActive` never selects it because `isActiveTool` matches with `startsWith(route.route)`. The page reuses the existing `ContentLayout`, `PageTitle`, `IconOutline` and `LinkButton` components and mirrors the styling of the existing `MemberNotFound` page. `/opportunities` now renders a clear "We were unable to find that page" message with a link home instead of a blank page. Once the Opportunities application merges, `/opportunities` matches its own route and the fallback is no longer reached. Any added/updated tests Added `src/apps/platform/src/routes/not-found/NotFound.spec.tsx`, which renders the platform home and fallback route shapes together and asserts that `/opportunities` renders the not-found content and that `/` still renders the home route, ie. the catch-all does not shadow an owned route. Co-Authored-By: Claude Opus 5 (1M context) --- src/apps/platform/src/platform.routes.tsx | 17 ++++++ .../src/routes/not-found/NotFound.module.scss | 31 +++++++++++ .../src/routes/not-found/NotFound.spec.tsx | 53 +++++++++++++++++++ .../src/routes/not-found/NotFound.tsx | 35 ++++++++++++ .../platform/src/routes/not-found/index.ts | 1 + 5 files changed, 137 insertions(+) create mode 100644 src/apps/platform/src/routes/not-found/NotFound.module.scss create mode 100644 src/apps/platform/src/routes/not-found/NotFound.spec.tsx create mode 100644 src/apps/platform/src/routes/not-found/NotFound.tsx create mode 100644 src/apps/platform/src/routes/not-found/index.ts diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index a39f1f82e..5a8f9d691 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -25,6 +25,11 @@ const Home: LazyLoadedComponent = lazyLoad( 'HomePage', ) +const NotFound: LazyLoadedComponent = lazyLoad( + () => import('./routes/not-found'), + 'NotFoundPage', +) + const homeRoutes: ReadonlyArray = [ { element: , @@ -33,6 +38,17 @@ const homeRoutes: ReadonlyArray = [ }, ] +// Catch-all for paths Platform UI is served for but does not own a route for, +// eg. `/opportunities` on the Topcoder apex host. React-router ranks the `*` +// path last, so this never shadows a route declared above. +const notFoundRoutes: ReadonlyArray = [ + { + element: , + id: 'Not found page', + route: '*', + }, +] + export const platformRoutes: Array = [ // NOTE: Order matters here bc the active tool // is determined by finding the first route @@ -57,4 +73,5 @@ export const platformRoutes: Array = [ ...adminRoutes, ...reportsRoutes, ...customerPortalRoutes, + ...notFoundRoutes, ] diff --git a/src/apps/platform/src/routes/not-found/NotFound.module.scss b/src/apps/platform/src/routes/not-found/NotFound.module.scss new file mode 100644 index 000000000..6fab0c29a --- /dev/null +++ b/src/apps/platform/src/routes/not-found/NotFound.module.scss @@ -0,0 +1,31 @@ +@import "@libs/ui/styles/includes"; + +.container { + min-height: 60vh; + display: flex; + align-items: center; + justify-content: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: $sp-6; + padding: $sp-15 $sp-8; + color: $black-100; + + svg { + color: $black-60; + } +} + +.title { + @include font-barlow; + font-weight: 600; + font-size: 28px; + line-height: 34px; + margin: 0; +} diff --git a/src/apps/platform/src/routes/not-found/NotFound.spec.tsx b/src/apps/platform/src/routes/not-found/NotFound.spec.tsx new file mode 100644 index 000000000..f2cb983d1 --- /dev/null +++ b/src/apps/platform/src/routes/not-found/NotFound.spec.tsx @@ -0,0 +1,53 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import type { PropsWithChildren } from 'react' +import { render, screen } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import NotFoundPage from './NotFound' + +jest.mock('~/libs/ui', () => ({ + ContentLayout: (props: PropsWithChildren): JSX.Element =>
{props.children}
, + IconOutline: { + ExclamationCircleIcon: (): JSX.Element => , + }, + LinkButton: (props: PropsWithChildren<{ to: string }>): JSX.Element => ( + {props.children} + ), + PageTitle: (): JSX.Element => <>, +}), { virtual: true }) + +// `getRouteElement` renders a childless `PlatformRoute` as ``, +// so these paths mirror the platform `homeRoutes` and `notFoundRoutes` entries. +function renderPlatformRoutes(pathname: string): void { + render( + + + home page} path='' /> + } path='*' /> + + , + ) +} + +describe('NotFoundPage', () => { + it('renders a message instead of a blank page for an unmatched path', () => { + renderPlatformRoutes('/opportunities') + + expect(screen.getByRole('alert')) + .toBeInTheDocument() + expect(screen.getByText('We were unable to find that page')) + .toBeInTheDocument() + expect(screen.getByText('Go to the home page')) + .toHaveAttribute('href', '/') + }) + + it('does not shadow a route the platform does own', () => { + renderPlatformRoutes('/') + + expect(screen.getByText('home page')) + .toBeInTheDocument() + expect(screen.queryByRole('alert')) + .not.toBeInTheDocument() + }) +}) diff --git a/src/apps/platform/src/routes/not-found/NotFound.tsx b/src/apps/platform/src/routes/not-found/NotFound.tsx new file mode 100644 index 000000000..790e6bd29 --- /dev/null +++ b/src/apps/platform/src/routes/not-found/NotFound.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react' + +import { + ContentLayout, + IconOutline, + LinkButton, + PageTitle, +} from '~/libs/ui' + +import styles from './NotFound.module.scss' + +/** + * Fallback page for a path Platform UI is served for but has no route for. + * + * Without it react-router matches nothing, the router renders an empty + * container inside the app shell, and the user is shown a blank page. + */ +const NotFoundPage: FC<{}> = () => ( + + Page Not Found | Topcoder + +
+ +

We were unable to find that page

+

+ The page you requested does not exist or is not available yet. +

+ + Go to the home page + +
+
+) + +export default NotFoundPage diff --git a/src/apps/platform/src/routes/not-found/index.ts b/src/apps/platform/src/routes/not-found/index.ts new file mode 100644 index 000000000..6668cd4c6 --- /dev/null +++ b/src/apps/platform/src/routes/not-found/index.ts @@ -0,0 +1 @@ +export { default as NotFoundPage } from './NotFound'