Skip to content

Commit 99ab2ba

Browse files
Deterministic 404 search (#682)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent e1cc4fd commit 99ab2ba

File tree

5 files changed

+576
-138
lines changed

5 files changed

+576
-138
lines changed

app/components/error-boundary.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,19 @@ export function GeneralErrorBoundary({
3333
console.error(error)
3434
}
3535

36+
if (isRouteErrorResponse(error)) {
37+
const handler = statusHandlers?.[error.status] ?? defaultStatusHandler
38+
39+
return (
40+
<div className="container mx-auto flex items-center justify-center p-4 lg:p-20">
41+
{handler({ error, params })}
42+
</div>
43+
)
44+
}
45+
3646
return (
37-
<div className="text-h2 container mx-auto flex items-center justify-center p-20">
38-
{isRouteErrorResponse(error)
39-
? (statusHandlers?.[error.status] ?? defaultStatusHandler)({
40-
error,
41-
params,
42-
})
43-
: unexpectedErrorHandler(error)}
47+
<div className="container mx-auto flex items-center justify-center p-4 lg:p-20">
48+
{unexpectedErrorHandler(error)}
4449
</div>
4550
)
4651
}

app/components/errors.tsx

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,23 @@ function ErrorPage({
7272
possibleMatchesQuery?: string
7373
heroProps: HeroSectionProps
7474
}) {
75-
const resolvedHeroProps: HeroSectionProps = possibleMatches?.length
76-
? {
77-
...heroProps,
78-
arrowUrl: '#possible-matches',
79-
arrowLabel: 'Possible matches',
80-
}
81-
: articles?.length
75+
// Only inject the "arrow down" helper link when the caller didn't provide an
76+
// explicit action. Otherwise, it can create duplicate CTAs (notably on 404s).
77+
const resolvedHeroProps: HeroSectionProps = heroProps.action
78+
? heroProps
79+
: possibleMatches?.length
8280
? {
8381
...heroProps,
84-
arrowUrl: '#articles',
85-
arrowLabel: 'But wait, there is more!',
82+
arrowUrl: '#possible-matches',
83+
arrowLabel: 'Possible matches',
8684
}
87-
: heroProps
85+
: articles?.length
86+
? {
87+
...heroProps,
88+
arrowUrl: '#articles',
89+
arrowLabel: 'But wait, there is more!',
90+
}
91+
: heroProps
8892
return (
8993
<>
9094
<noscript>
@@ -108,7 +112,7 @@ function ErrorPage({
108112
) : null}
109113
<HeroSection {...resolvedHeroProps} />
110114

111-
{possibleMatches?.length ? (
115+
{possibleMatches ? (
112116
<PossibleMatchesSection
113117
matches={possibleMatches}
114118
query={possibleMatchesQuery}
@@ -140,15 +144,16 @@ function PossibleMatchesSection({
140144
const q = typeof query === 'string' ? query.trim() : ''
141145
const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search'
142146
const sorted = sortNotFoundMatches(matches)
147+
const hasMatches = sorted.length > 0
143148

144149
return (
145150
<>
146151
<div id="possible-matches" />
147152
<HeaderSection
148153
title="Possible matches"
149-
subTitle={q ? `Semantic search for "${q}"` : 'Semantic search results.'}
150-
cta="Search the site"
151-
ctaUrl={searchUrl}
154+
subTitle={
155+
q ? `Closest matches for "${q}"` : 'Closest matches.'
156+
}
152157
/>
153158
<Spacer size="2xs" />
154159
<Grid>
@@ -157,19 +162,19 @@ function PossibleMatchesSection({
157162
{sorted.slice(0, 8).map((m) => (
158163
<li
159164
key={`${m.type}:${m.url}`}
160-
className="rounded-lg bg-gray-100 p-6 dark:bg-gray-800"
165+
className="rounded-lg bg-gray-100 p-4 sm:p-6 dark:bg-gray-800"
161166
>
162-
<div className="flex items-start gap-4">
167+
<div className="flex items-start gap-3 sm:gap-4">
163168
<div className="shrink-0">
164169
{m.imageUrl ? (
165170
<img
166171
src={m.imageUrl}
167172
alt={m.imageAlt ?? ''}
168-
className="h-16 w-16 rounded-lg object-cover"
173+
className="h-12 w-12 rounded-lg object-cover sm:h-16 sm:w-16"
169174
loading="lazy"
170175
/>
171176
) : (
172-
<div className="h-16 w-16 rounded-lg bg-gray-200 dark:bg-gray-700" />
177+
<div className="h-12 w-12 rounded-lg bg-gray-200 sm:h-16 sm:w-16 dark:bg-gray-700" />
173178
)}
174179
</div>
175180
<div className="min-w-0 flex-1">
@@ -183,7 +188,7 @@ function PossibleMatchesSection({
183188
<span className="truncate">{m.url}</span>
184189
</div>
185190
{m.summary ? (
186-
<p className="mt-3 line-clamp-3 text-base text-slate-600 dark:text-slate-400">
191+
<p className="mt-2 line-clamp-3 text-base text-slate-600 sm:mt-3 dark:text-slate-400">
187192
{m.summary}
188193
</p>
189194
) : null}
@@ -192,13 +197,13 @@ function PossibleMatchesSection({
192197
</li>
193198
))}
194199
</ul>
195-
{sorted.length > 8 ? (
196-
<p className="mt-4 text-sm text-slate-500">
197-
<a href={searchUrl} className="underlined">
198-
See all results
199-
</a>
200-
</p>
201-
) : null}
200+
<p className="mt-4 text-sm text-slate-500">
201+
{hasMatches ? 'None of these match? ' : 'No close matches found. '}
202+
<a href={searchUrl} className="underlined">
203+
Try semantic search
204+
</a>
205+
.
206+
</p>
202207
</div>
203208
</Grid>
204209
</>
@@ -228,23 +233,38 @@ function FourOhFour({
228233

229234
const q = effectiveQuery ? effectiveQuery.trim() : ''
230235
const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search'
231-
const heroActionTo =
236+
const hasPossibleMatches =
232237
Array.isArray(possibleMatchesProp) && possibleMatchesProp.length > 0
233-
? '#possible-matches'
234-
: searchUrl
238+
const heroActionTo = hasPossibleMatches ? '#possible-matches' : searchUrl
239+
const heroActionLabel = hasPossibleMatches
240+
? 'Possible matches'
241+
: 'Search the site'
242+
243+
// Most pages intentionally use the global `mx-10vw` gutter (it’s part of the
244+
// overall site layout). The 404 view reads better on mobile when it’s a bit
245+
// wider, so we override the underlying spacing token for just this subtree.
246+
const notFoundGutterStyle = {
247+
['--spacing-10vw' as any]: 'clamp(0.75rem, 3vw, 3rem)',
248+
} as React.CSSProperties
235249

236250
return (
237-
<ErrorPage
238-
articles={articles}
239-
possibleMatches={possibleMatchesProp}
240-
possibleMatchesQuery={effectiveQuery}
241-
heroProps={{
242-
title: "404 - Oh no, you found a page that's missing stuff.",
243-
subtitle: `"${pathname}" is not a page on kentcdodds.com. So sorry.`,
244-
image: <MissingSomething className="rounded-lg" aspectRatio="3:4" />,
245-
action: <ArrowLink to={heroActionTo}>Possible matches</ArrowLink>,
246-
}}
247-
/>
251+
<div style={notFoundGutterStyle}>
252+
<ErrorPage
253+
articles={articles}
254+
possibleMatches={possibleMatchesProp}
255+
possibleMatchesQuery={effectiveQuery}
256+
heroProps={{
257+
title: "404 - Oh no, you found a page that's missing stuff.",
258+
subtitle: `"${pathname}" is not a page on kentcdodds.com. So sorry.`,
259+
image: <MissingSomething className="rounded-lg" aspectRatio="3:4" />,
260+
action: (
261+
<ArrowLink to={heroActionTo} className="whitespace-nowrap">
262+
{heroActionLabel}
263+
</ArrowLink>
264+
),
265+
}}
266+
/>
267+
</div>
248268
)
249269
}
250270

app/utils/not-found-query.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ function safeDecodeURIComponent(value: string) {
77
}
88

99
/**
10-
* Convert a missing pathname into a semantic-search-friendly query string.
10+
* Convert a missing pathname into a search-friendly query string.
1111
*
1212
* Example: `/blog/react-testing-library` -> `blog react testing library`
1313
*/
@@ -28,6 +28,6 @@ export function notFoundQueryFromPathname(pathname: string) {
2828
.replace(/\s+/g, ' ')
2929
.trim()
3030

31-
// Keep queries reasonably small; embeddings don't benefit from very long URL-ish strings.
31+
// Keep queries reasonably small; search doesn't benefit from very long URL-ish strings.
3232
return words.length > 120 ? words.slice(0, 120).trim() : words
3333
}

0 commit comments

Comments
 (0)