Skip to content

Commit 48f86e3

Browse files
404 content matching (#675)
* Add semantic matches to 404 pages Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix lint for 404 suggestions Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Replace 404 Go home with matches link Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix 404 suggestions for updated semantic search Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Harden 404 match URLs and fallback Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Avoid 404 heroProps mutation and bound suggestion latency Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Extract normalizeNotFoundUrl helper * Harden 404 URL normalization and timeout sentinel Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix 404 pathname in root error boundary * Remove client-side 404 match fetch Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 24560b2 commit 48f86e3

File tree

9 files changed

+497
-60
lines changed

9 files changed

+497
-60
lines changed

app/components/errors.tsx

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@ import * as React from 'react'
44
import { useMatches } from 'react-router'
55
import { type MdxListItem } from '#app/types.ts'
66
import { getErrorMessage } from '#app/utils/misc.ts'
7+
import {
8+
type NotFoundMatch,
9+
sortNotFoundMatches,
10+
} from '#app/utils/not-found-matches.ts'
11+
import { notFoundQueryFromPathname } from '#app/utils/not-found-query.ts'
712
import { ArrowLink } from './arrow-button.tsx'
13+
import { Grid } from './grid.tsx'
814
import { Facepalm, Grimmacing, MissingSomething } from './kifs.tsx'
915
import { BlogSection } from './sections/blog-section.tsx'
16+
import { HeaderSection } from './sections/header-section.tsx'
1017
import { HeroSection, type HeroSectionProps } from './sections/hero-section.tsx'
11-
import { H2, H6 } from './typography.tsx'
18+
import { Spacer } from './spacer.tsx'
19+
import { H2, H4, H6 } from './typography.tsx'
1220

1321
function RedBox({ error }: { error: Error }) {
1422
const [isVisible, setIsVisible] = React.useState(true)
@@ -54,18 +62,21 @@ function RedBox({ error }: { error: Error }) {
5462
function ErrorPage({
5563
error,
5664
articles,
65+
possibleMatches,
66+
possibleMatchesQuery,
5767
heroProps,
5868
}: {
5969
error?: Error
6070
articles?: Array<MdxListItem>
71+
possibleMatches?: Array<NotFoundMatch>
72+
possibleMatchesQuery?: string
6173
heroProps: HeroSectionProps
6274
}) {
63-
if (articles?.length) {
64-
Object.assign(heroProps, {
65-
arrowUrl: '#articles',
66-
arrowLabel: 'But wait, there is more!',
67-
})
68-
}
75+
const resolvedHeroProps: HeroSectionProps = possibleMatches?.length
76+
? { ...heroProps, arrowUrl: '#possible-matches', arrowLabel: 'Possible matches' }
77+
: articles?.length
78+
? { ...heroProps, arrowUrl: '#articles', arrowLabel: 'But wait, there is more!' }
79+
: heroProps
6980
return (
7081
<>
7182
<noscript>
@@ -87,7 +98,14 @@ function ErrorPage({
8798
{error && import.meta.env.MODE === 'development' ? (
8899
<RedBox error={error} />
89100
) : null}
90-
<HeroSection {...heroProps} />
101+
<HeroSection {...resolvedHeroProps} />
102+
103+
{possibleMatches?.length ? (
104+
<PossibleMatchesSection
105+
matches={possibleMatches}
106+
query={possibleMatchesQuery}
107+
/>
108+
) : null}
91109

92110
{articles?.length ? (
93111
<>
@@ -104,19 +122,118 @@ function ErrorPage({
104122
)
105123
}
106124

107-
function FourOhFour({ articles }: { articles?: Array<MdxListItem> }) {
108-
const matches = useMatches()
109-
const last = matches[matches.length - 1]
110-
const pathname = last?.pathname
125+
function PossibleMatchesSection({
126+
matches,
127+
query,
128+
}: {
129+
matches: Array<NotFoundMatch>
130+
query?: string
131+
}) {
132+
const q = typeof query === 'string' ? query.trim() : ''
133+
const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search'
134+
const sorted = sortNotFoundMatches(matches)
135+
136+
return (
137+
<>
138+
<div id="possible-matches" />
139+
<HeaderSection
140+
title="Possible matches"
141+
subTitle={q ? `Semantic search for "${q}"` : 'Semantic search results.'}
142+
cta="Search the site"
143+
ctaUrl={searchUrl}
144+
/>
145+
<Spacer size="2xs" />
146+
<Grid>
147+
<div className="col-span-full lg:col-span-8 lg:col-start-3">
148+
<ul className="space-y-6">
149+
{sorted.slice(0, 8).map((m) => (
150+
<li
151+
key={`${m.type}:${m.url}`}
152+
className="rounded-lg bg-gray-100 p-6 dark:bg-gray-800"
153+
>
154+
<div className="flex items-start gap-4">
155+
<div className="shrink-0">
156+
{m.imageUrl ? (
157+
<img
158+
src={m.imageUrl}
159+
alt={m.imageAlt ?? ''}
160+
className="h-16 w-16 rounded-lg object-cover"
161+
loading="lazy"
162+
/>
163+
) : (
164+
<div className="h-16 w-16 rounded-lg bg-gray-200 dark:bg-gray-700" />
165+
)}
166+
</div>
167+
<div className="min-w-0 flex-1">
168+
<H4 className="truncate">
169+
<a href={m.url} className="hover:underline">
170+
{m.title}
171+
</a>
172+
</H4>
173+
<div className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-slate-500">
174+
<span>{m.type}</span>
175+
<span className="truncate">{m.url}</span>
176+
</div>
177+
{m.summary ? (
178+
<p className="mt-3 line-clamp-3 text-base text-slate-600 dark:text-slate-400">
179+
{m.summary}
180+
</p>
181+
) : null}
182+
</div>
183+
</div>
184+
</li>
185+
))}
186+
</ul>
187+
{sorted.length > 8 ? (
188+
<p className="mt-4 text-sm text-slate-500">
189+
<a href={searchUrl} className="underlined">
190+
See all results
191+
</a>
192+
</p>
193+
) : null}
194+
</div>
195+
</Grid>
196+
</>
197+
)
198+
}
199+
200+
function FourOhFour({
201+
articles,
202+
possibleMatches: possibleMatchesProp,
203+
possibleMatchesQuery,
204+
pathname: pathnameProp,
205+
}: {
206+
articles?: Array<MdxListItem>
207+
possibleMatches?: Array<NotFoundMatch>
208+
possibleMatchesQuery?: string
209+
pathname?: string
210+
}) {
211+
const routeMatches = useMatches()
212+
const last = routeMatches[routeMatches.length - 1]
213+
const pathname = typeof pathnameProp === 'string' ? pathnameProp : last?.pathname
214+
const derivedQuery = notFoundQueryFromPathname(pathname ?? '/')
215+
const effectiveQuery =
216+
typeof possibleMatchesQuery === 'string' && possibleMatchesQuery.trim()
217+
? possibleMatchesQuery.trim()
218+
: derivedQuery
219+
220+
const q = effectiveQuery ? effectiveQuery.trim() : ''
221+
const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search'
222+
const heroActionTo =
223+
Array.isArray(possibleMatchesProp) && possibleMatchesProp.length > 0
224+
? '#possible-matches'
225+
: searchUrl
111226

112227
return (
113228
<ErrorPage
114229
articles={articles}
230+
possibleMatches={possibleMatchesProp}
231+
possibleMatchesQuery={effectiveQuery}
115232
heroProps={{
116233
title: "404 - Oh no, you found a page that's missing stuff.",
117234
subtitle: `"${pathname}" is not a page on kentcdodds.com. So sorry.`,
118235
image: <MissingSomething className="rounded-lg" aspectRatio="3:4" />,
119-
action: <ArrowLink href="/">Go home</ArrowLink>,
236+
action: <ArrowLink to={heroActionTo}>Possible matches</ArrowLink>,
120237
}}
121238
/>
122239
)

app/root.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ import {
3030
import { type Route } from './+types/root'
3131
import { AppHotkeys } from './components/app-hotkeys.tsx'
3232
import { ArrowLink } from './components/arrow-button.tsx'
33-
import { ErrorPage, FourHundred } from './components/errors.tsx'
33+
import { ErrorPage, FourHundred, FourOhFour } from './components/errors.tsx'
3434
import { Footer } from './components/footer.tsx'
35-
import { Grimmacing, MissingSomething } from './components/kifs.tsx'
35+
import { Grimmacing } from './components/kifs.tsx'
3636
import { Navbar } from './components/navbar.tsx'
3737
import { NotificationMessage } from './components/notification-message.tsx'
3838
import { Spacer } from './components/spacer.tsx'
@@ -437,16 +437,7 @@ export function ErrorBoundary() {
437437
if (error.status === 404) {
438438
return (
439439
<ErrorDoc>
440-
<ErrorPage
441-
heroProps={{
442-
title: "404 - Oh no, you found a page that's missing stuff.",
443-
subtitle: `"${location.pathname}" is not a page on kentcdodds.com. So sorry.`,
444-
image: (
445-
<MissingSomething className="rounded-lg" aspectRatio="3:4" />
446-
),
447-
action: <ArrowLink href="/">Go home</ArrowLink>,
448-
}}
449-
/>
440+
<FourOhFour pathname={location.pathname} />
450441
</ErrorDoc>
451442
)
452443
}

app/routes/$.tsx

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,40 @@
55
// ensure the user gets the right status code and we can display a nicer error
66
// message for them than the Remix and/or browser default.
77

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

14-
export async function loader() {
15-
throw new Response('Not found', { status: 404 })
16+
export async function loader({ request }: { request: Request }) {
17+
const accept = request.headers.get('accept') ?? ''
18+
const wantsHtml =
19+
accept.includes('text/html') || accept.includes('application/xhtml+xml')
20+
if (!wantsHtml || request.method.toUpperCase() !== 'GET') {
21+
throw new Response('Not found', { status: 404 })
22+
}
23+
24+
const pathname = new URL(request.url).pathname
25+
const suggestions = await getNotFoundSuggestions({ request, pathname, limit: 8 })
26+
27+
const data: {
28+
possibleMatches?: Array<NotFoundMatch>
29+
possibleMatchesQuery?: string
30+
} = {}
31+
if (suggestions) {
32+
data.possibleMatches = suggestions.matches
33+
data.possibleMatchesQuery = suggestions.query
34+
}
35+
36+
throw json(data, {
37+
status: 404,
38+
headers: {
39+
'Cache-Control': 'private, max-age=60',
40+
},
41+
})
1642
}
1743

1844
export default function NotFound() {
@@ -22,7 +48,6 @@ export default function NotFound() {
2248
}
2349

2450
export function ErrorBoundary() {
25-
const location = useLocation()
2651
return (
2752
<GeneralErrorBoundary
2853
statusHandlers={{
@@ -36,16 +61,10 @@ export function ErrorBoundary() {
3661
}}
3762
/>
3863
),
39-
404: () => (
40-
<ErrorPage
41-
heroProps={{
42-
title: "404 - Oh no, you found a page that's missing stuff.",
43-
subtitle: `"${location.pathname}" is not a page on kentcdodds.com. So sorry.`,
44-
image: (
45-
<MissingSomething className="rounded-lg" aspectRatio="3:4" />
46-
),
47-
action: <ArrowLink href="/">Go home</ArrowLink>,
48-
}}
64+
404: ({ error }) => (
65+
<FourOhFour
66+
possibleMatches={error.data.possibleMatches}
67+
possibleMatchesQuery={error.data.possibleMatchesQuery}
4968
/>
5069
),
5170
}}

app/routes/$slug.tsx

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
useMdxComponent,
2020
} from '#app/utils/mdx.tsx'
2121
import { requireValidSlug, reuseUsefulLoaderHeaders } from '#app/utils/misc.ts'
22+
import { type NotFoundMatch } from '#app/utils/not-found-matches.ts'
23+
import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts'
2224
import { getServerTimeHeader } from '#app/utils/timing.server.ts'
2325
import { type Route } from './+types/$slug'
2426

@@ -42,24 +44,47 @@ export async function loader({ params, request }: Route.LoaderArgs) {
4244
}
4345

4446
const timings = {}
47+
const pathname = new URL(request.url).pathname
4548
const page = await getMdxPage(
4649
{ contentDir: 'pages', slug: params.slug },
4750
{ request, timings },
4851
).catch(() => null)
4952

50-
const headers = {
51-
'Cache-Control': 'private, max-age=3600',
52-
Vary: 'Cookie',
53-
'Server-Timing': getServerTimeHeader(timings),
54-
}
5553
if (!page) {
56-
const blogRecommendations = await getBlogRecommendations({
57-
request,
58-
timings,
54+
const [recommendations, suggestions] = await Promise.all([
55+
getBlogRecommendations({ request, timings }),
56+
getNotFoundSuggestions({ request, pathname, limit: 8 }),
57+
])
58+
const data: {
59+
recommendations: Array<unknown>
60+
possibleMatches?: Array<NotFoundMatch>
61+
possibleMatchesQuery?: string
62+
} = { recommendations }
63+
if (suggestions) {
64+
data.possibleMatches = suggestions.matches
65+
data.possibleMatchesQuery = suggestions.query
66+
}
67+
throw json(data, {
68+
status: 404,
69+
headers: {
70+
// Don't cache speculative 404 slugs for long.
71+
'Cache-Control': 'private, max-age=60',
72+
Vary: 'Cookie',
73+
'Server-Timing': getServerTimeHeader(timings),
74+
},
5975
})
60-
throw json({ blogRecommendations }, { status: 404, headers })
6176
}
62-
return json({ page }, { status: 200, headers })
77+
return json(
78+
{ page },
79+
{
80+
status: 200,
81+
headers: {
82+
'Cache-Control': 'private, max-age=3600',
83+
Vary: 'Cookie',
84+
'Server-Timing': getServerTimeHeader(timings),
85+
},
86+
},
87+
)
6388
}
6489

6590
export const headers: HeadersFunction = reuseUsefulLoaderHeaders
@@ -154,7 +179,11 @@ export function ErrorBoundary() {
154179
statusHandlers={{
155180
400: ({ error }) => <FourHundred error={error.data} />,
156181
404: ({ error }) => (
157-
<FourOhFour articles={error.data.recommendations} />
182+
<FourOhFour
183+
articles={error.data.recommendations}
184+
possibleMatches={error.data.possibleMatches}
185+
possibleMatchesQuery={error.data.possibleMatchesQuery}
186+
/>
158187
),
159188
}}
160189
/>

0 commit comments

Comments
 (0)