Skip to content
174 changes: 167 additions & 7 deletions app/components/errors.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { clsx } from 'clsx'
import errorStack from 'error-stack-parser'
import * as React from 'react'
import { useMatches } from 'react-router'
import { useFetcher, useMatches } from 'react-router'
import { type MdxListItem } from '#app/types.ts'
import { getErrorMessage } from '#app/utils/misc.ts'
import { type NotFoundMatch, sortNotFoundMatches } from '#app/utils/not-found-matches.ts'
import { notFoundQueryFromPathname } from '#app/utils/not-found-query.ts'
import { ArrowLink } from './arrow-button.tsx'
import { Grid } from './grid.tsx'
import { Facepalm, Grimmacing, MissingSomething } from './kifs.tsx'
import { BlogSection } from './sections/blog-section.tsx'
import { HeaderSection } from './sections/header-section.tsx'
import { HeroSection, type HeroSectionProps } from './sections/hero-section.tsx'
import { H2, H6 } from './typography.tsx'
import { Spacer } from './spacer.tsx'
import { H2, H4, H6 } from './typography.tsx'

function RedBox({ error }: { error: Error }) {
const [isVisible, setIsVisible] = React.useState(true)
Expand Down Expand Up @@ -54,13 +59,22 @@ function RedBox({ error }: { error: Error }) {
function ErrorPage({
error,
articles,
possibleMatches,
possibleMatchesQuery,
heroProps,
}: {
error?: Error
articles?: Array<MdxListItem>
possibleMatches?: Array<NotFoundMatch>
possibleMatchesQuery?: string
heroProps: HeroSectionProps
}) {
if (articles?.length) {
if (possibleMatches?.length) {
Object.assign(heroProps, {
arrowUrl: '#possible-matches',
arrowLabel: 'Possible matches',
})
} else if (articles?.length) {
Object.assign(heroProps, {
arrowUrl: '#articles',
arrowLabel: 'But wait, there is more!',
Expand Down Expand Up @@ -89,6 +103,19 @@ function ErrorPage({
) : null}
<HeroSection {...heroProps} />

{possibleMatchesQuery && !possibleMatches?.length ? (
// Ensure in-page links to `#possible-matches` have a target even
// before semantic results load.
<div id="possible-matches" />
) : null}

{possibleMatches?.length ? (
<PossibleMatchesSection
matches={possibleMatches}
query={possibleMatchesQuery}
/>
) : null}

{articles?.length ? (
<>
<div id="articles" />
Expand All @@ -104,19 +131,152 @@ function ErrorPage({
)
}

function FourOhFour({ articles }: { articles?: Array<MdxListItem> }) {
const matches = useMatches()
const last = matches[matches.length - 1]
function PossibleMatchesSection({
matches,
query,
}: {
matches: Array<NotFoundMatch>
query?: string
}) {
const q = typeof query === 'string' ? query.trim() : ''
const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search'
const sorted = sortNotFoundMatches(matches)

return (
<>
<div id="possible-matches" />
<HeaderSection
title="Possible matches"
subTitle={q ? `Semantic search for "${q}"` : 'Semantic search results.'}
cta="Search the site"
ctaUrl={searchUrl}
/>
<Spacer size="2xs" />
<Grid>
<div className="col-span-full lg:col-span-8 lg:col-start-3">
<ul className="space-y-6">
{sorted.slice(0, 8).map((m) => (
<li
key={`${m.type}:${m.url}`}
className="rounded-lg bg-gray-100 p-6 dark:bg-gray-800"
>
<div className="flex items-start gap-4">
<div className="shrink-0">
{m.imageUrl ? (
<img
src={m.imageUrl}
alt={m.imageAlt ?? ''}
className="h-16 w-16 rounded-lg object-cover"
loading="lazy"
/>
) : (
<div className="h-16 w-16 rounded-lg bg-gray-200 dark:bg-gray-700" />
)}
</div>
<div className="min-w-0 flex-1">
<H4 className="truncate">
<a href={m.url} className="hover:underline">
{m.title}
</a>
</H4>
<div className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-slate-500">
<span>{m.type}</span>
<span className="truncate">{m.url}</span>
</div>
{m.summary ? (
<p className="mt-3 line-clamp-3 text-base text-slate-600 dark:text-slate-400">
{m.summary}
</p>
) : null}
</div>
</div>
</li>
))}
</ul>
{sorted.length > 8 ? (
<p className="mt-4 text-sm text-slate-500">
<a href={searchUrl} className="underlined">
See all results
</a>
</p>
) : null}
</div>
</Grid>
</>
)
}

function asNotFoundMatchFromResourceSearch(value: unknown): NotFoundMatch | null {
if (!value || typeof value !== 'object') return null
const v = value as Record<string, unknown>
const url = typeof v.url === 'string' ? v.url.trim() : ''
if (!url) return null
const titleRaw = typeof v.title === 'string' ? v.title.trim() : ''
const segmentRaw = typeof v.segment === 'string' ? v.segment.trim() : ''
const summaryRaw = typeof v.summary === 'string' ? v.summary.trim() : ''
const imageUrlRaw = typeof v.imageUrl === 'string' ? v.imageUrl.trim() : ''
const imageAltRaw = typeof v.imageAlt === 'string' ? v.imageAlt.trim() : ''
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

imageUrl is not normalized through normalizeNotFoundUrl, unlike url.

Line 211 correctly pipes v.url through normalizeNotFoundUrl, but imageUrlRaw at line 216 is used as-is. While javascript: in <img src> doesn't execute in modern browsers, an unexpected scheme or absolute external URL from a compromised search response can still leak the user's IP/referrer via image load. Consistent sanitization keeps the surface predictable.

🛡️ Proposed fix
-	const imageUrlRaw = typeof v.imageUrl === 'string' ? v.imageUrl.trim() : ''
+	const imageUrlRaw =
+		typeof v.imageUrl === 'string'
+			? normalizeNotFoundUrl(v.imageUrl.trim())
+			: ''
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const imageUrlRaw = typeof v.imageUrl === 'string' ? v.imageUrl.trim() : ''
const imageAltRaw = typeof v.imageAlt === 'string' ? v.imageAlt.trim() : ''
const imageUrlRaw =
typeof v.imageUrl === 'string'
? normalizeNotFoundUrl(v.imageUrl.trim())
: ''
const imageAltRaw = typeof v.imageAlt === 'string' ? v.imageAlt.trim() : ''
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/errors.tsx` around lines 216 - 217, The imageUrl value is not
being normalized like v.url; update the code that computes imageUrlRaw to first
coerce and trim v.imageUrl and then pass it through normalizeNotFoundUrl (same
as is done for v.url) so imageUrlRaw uses
normalizeNotFoundUrl(v.imageUrl?.trim() ?? '') before use; keep imageAltRaw
handling unchanged and ensure you reference normalizeNotFoundUrl, v.imageUrl,
and imageUrlRaw when making the change.

return {
url,
type: segmentRaw || 'result',
title: titleRaw || url,
summary: summaryRaw || undefined,
imageUrl: imageUrlRaw || undefined,
imageAlt: imageAltRaw || undefined,
}
}

function FourOhFour({
articles,
possibleMatches: possibleMatchesProp,
possibleMatchesQuery,
}: {
articles?: Array<MdxListItem>
possibleMatches?: Array<NotFoundMatch>
possibleMatchesQuery?: string
}) {
const routeMatches = useMatches()
const last = routeMatches[routeMatches.length - 1]
const pathname = last?.pathname
const derivedQuery = notFoundQueryFromPathname(pathname ?? '/')
const effectiveQuery =
typeof possibleMatchesQuery === 'string' && possibleMatchesQuery.trim()
? possibleMatchesQuery.trim()
: derivedQuery

const fetcher = useFetcher({ key: 'four-oh-four-possible-matches' })
const requestedQueryRef = React.useRef<string>('')

React.useEffect(() => {
if (possibleMatchesProp != null) return
if (!effectiveQuery) return
if (requestedQueryRef.current === effectiveQuery) return
requestedQueryRef.current = effectiveQuery
void fetcher.load(
`/resources/search?query=${encodeURIComponent(effectiveQuery)}`,
)
}, [effectiveQuery, fetcher, possibleMatchesProp])

const fetchedMatches = React.useMemo(() => {
const data = fetcher.data
if (!Array.isArray(data)) return undefined
return data
.map((v) => asNotFoundMatchFromResourceSearch(v))
.filter((v): v is NotFoundMatch => Boolean(v))
}, [fetcher.data])

const possibleMatches = possibleMatchesProp ?? fetchedMatches

return (
<ErrorPage
articles={articles}
possibleMatches={possibleMatches}
possibleMatchesQuery={effectiveQuery}
heroProps={{
title: "404 - Oh no, you found a page that's missing stuff.",
subtitle: `"${pathname}" is not a page on kentcdodds.com. So sorry.`,
image: <MissingSomething className="rounded-lg" aspectRatio="3:4" />,
action: <ArrowLink href="/">Go home</ArrowLink>,
action: <ArrowLink to="#possible-matches">Possible matches</ArrowLink>,
}}
/>
)
Expand Down
15 changes: 3 additions & 12 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ import {
import { type Route } from './+types/root'
import { AppHotkeys } from './components/app-hotkeys.tsx'
import { ArrowLink } from './components/arrow-button.tsx'
import { ErrorPage, FourHundred } from './components/errors.tsx'
import { ErrorPage, FourHundred, FourOhFour } from './components/errors.tsx'
import { Footer } from './components/footer.tsx'
import { Grimmacing, MissingSomething } from './components/kifs.tsx'
import { Grimmacing } from './components/kifs.tsx'
import { Navbar } from './components/navbar.tsx'
import { NotificationMessage } from './components/notification-message.tsx'
import { Spacer } from './components/spacer.tsx'
Expand Down Expand Up @@ -437,16 +437,7 @@ export function ErrorBoundary() {
if (error.status === 404) {
return (
<ErrorDoc>
<ErrorPage
heroProps={{
title: "404 - Oh no, you found a page that's missing stuff.",
subtitle: `"${location.pathname}" is not a page on kentcdodds.com. So sorry.`,
image: (
<MissingSomething className="rounded-lg" aspectRatio="3:4" />
),
action: <ArrowLink href="/">Go home</ArrowLink>,
}}
/>
<FourOhFour />
</ErrorDoc>
)
}
Expand Down
51 changes: 35 additions & 16 deletions app/routes/$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,40 @@
// ensure the user gets the right status code and we can display a nicer error
// message for them than the Remix and/or browser default.

import { useLocation } from 'react-router'
import { data as json } from 'react-router'
import { ArrowLink } from '#app/components/arrow-button.tsx'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { ErrorPage } from '#app/components/errors.tsx'
import { Facepalm, MissingSomething } from '#app/components/kifs.tsx'
import { ErrorPage, FourOhFour } from '#app/components/errors.tsx'
import { Facepalm } from '#app/components/kifs.tsx'
import { type NotFoundMatch } from '#app/utils/not-found-matches.ts'
import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts'

export async function loader() {
throw new Response('Not found', { status: 404 })
export async function loader({ request }: { request: Request }) {
const accept = request.headers.get('accept') ?? ''
const wantsHtml =
accept.includes('text/html') || accept.includes('application/xhtml+xml')
if (!wantsHtml || request.method.toUpperCase() !== 'GET') {
throw new Response('Not found', { status: 404 })
}

const pathname = new URL(request.url).pathname
const suggestions = await getNotFoundSuggestions({ request, pathname, limit: 8 })

const data: {
possibleMatches?: Array<NotFoundMatch>
possibleMatchesQuery?: string
} = {}
if (suggestions) {
data.possibleMatches = suggestions.matches
data.possibleMatchesQuery = suggestions.query
}

throw json(data, {
status: 404,
headers: {
'Cache-Control': 'private, max-age=60',
},
})
}

export default function NotFound() {
Expand All @@ -22,7 +48,6 @@ export default function NotFound() {
}

export function ErrorBoundary() {
const location = useLocation()
return (
<GeneralErrorBoundary
statusHandlers={{
Expand All @@ -36,16 +61,10 @@ export function ErrorBoundary() {
}}
/>
),
404: () => (
<ErrorPage
heroProps={{
title: "404 - Oh no, you found a page that's missing stuff.",
subtitle: `"${location.pathname}" is not a page on kentcdodds.com. So sorry.`,
image: (
<MissingSomething className="rounded-lg" aspectRatio="3:4" />
),
action: <ArrowLink href="/">Go home</ArrowLink>,
}}
404: ({ error }) => (
<FourOhFour
possibleMatches={error.data.possibleMatches}
possibleMatchesQuery={error.data.possibleMatchesQuery}
/>
),
}}
Expand Down
Loading
Loading