From 066fd1224eee762842d7f1c4bf9b7216889959a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 22:41:41 +0000 Subject: [PATCH 01/10] Add semantic matches to 404 pages Co-authored-by: Kent C. Dodds --- app/components/errors.tsx | 164 +++++++++++++++++++++- app/root.tsx | 15 +- app/routes/$.tsx | 51 ++++--- app/routes/$slug.tsx | 51 +++++-- app/routes/blog_/$slug.tsx | 32 +++-- app/utils/not-found-matches.ts | 45 ++++++ app/utils/not-found-query.ts | 34 +++++ app/utils/not-found-suggestions.server.ts | 114 +++++++++++++++ 8 files changed, 453 insertions(+), 53 deletions(-) create mode 100644 app/utils/not-found-matches.ts create mode 100644 app/utils/not-found-query.ts create mode 100644 app/utils/not-found-suggestions.server.ts diff --git a/app/components/errors.tsx b/app/components/errors.tsx index 475bde94e..29478971c 100644 --- a/app/components/errors.tsx +++ b/app/components/errors.tsx @@ -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 { type NotFoundMatch, sortNotFoundMatches } from '#app/utils/not-found-matches.ts' +import { notFoundQueryFromPathname } from '#app/utils/not-found-query.ts' import { getErrorMessage } from '#app/utils/misc.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) @@ -54,13 +59,22 @@ function RedBox({ error }: { error: Error }) { function ErrorPage({ error, articles, + possibleMatches, + possibleMatchesQuery, heroProps, }: { error?: Error articles?: Array + possibleMatches?: Array + 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!', @@ -89,6 +103,13 @@ function ErrorPage({ ) : null} + {possibleMatches?.length ? ( + + ) : null} + {articles?.length ? ( <>
@@ -104,14 +125,145 @@ function ErrorPage({ ) } -function FourOhFour({ articles }: { articles?: Array }) { - const matches = useMatches() - const last = matches[matches.length - 1] +function PossibleMatchesSection({ + matches, + query, +}: { + matches: Array + query?: string +}) { + const q = typeof query === 'string' ? query.trim() : '' + const searchUrl = q ? `/search?q=${encodeURIComponent(q)}` : '/search' + const sorted = sortNotFoundMatches(matches) + + return ( + <> +
+ + + +
+
    + {sorted.slice(0, 8).map((m) => ( +
  • +
    +
    + {m.imageUrl ? ( + {m.imageAlt + ) : ( +
    + )} +
    +
    +

    + + {m.title} + +

    +
    + {m.type} + {m.url} +
    + {m.summary ? ( +

    + {m.summary} +

    + ) : null} +
    +
    +
  • + ))} +
+ {sorted.length > 8 ? ( +

+ + See all results + +

+ ) : null} +
+
+ + ) +} + +function asNotFoundMatchFromResourceSearch(value: unknown): NotFoundMatch | null { + if (!value || typeof value !== 'object') return null + const v = value as Record + 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() : '' + 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 + possibleMatches?: Array + 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('') + + React.useEffect(() => { + if (possibleMatchesProp != null) return + if (!effectiveQuery) return + if (requestedQueryRef.current === effectiveQuery) return + requestedQueryRef.current = effectiveQuery + 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 ( - - ), - action: Go home, - }} - /> + ) } diff --git a/app/routes/$.tsx b/app/routes/$.tsx index c556979dd..e6481168d 100644 --- a/app/routes/$.tsx +++ b/app/routes/$.tsx @@ -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 + 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() { @@ -22,7 +48,6 @@ export default function NotFound() { } export function ErrorBoundary() { - const location = useLocation() return ( ), - 404: () => ( - - ), - action: Go home, - }} + 404: ({ error }) => ( + ), }} diff --git a/app/routes/$slug.tsx b/app/routes/$slug.tsx index a0119ed88..37bb4b829 100644 --- a/app/routes/$slug.tsx +++ b/app/routes/$slug.tsx @@ -18,7 +18,9 @@ import { mdxPageMeta, useMdxComponent, } from '#app/utils/mdx.tsx' +import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' import { requireValidSlug, reuseUsefulLoaderHeaders } from '#app/utils/misc.ts' +import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts' import { getServerTimeHeader } from '#app/utils/timing.server.ts' import { type Route } from './+types/$slug' @@ -42,24 +44,47 @@ export async function loader({ params, request }: Route.LoaderArgs) { } const timings = {} + const pathname = new URL(request.url).pathname const page = await getMdxPage( { contentDir: 'pages', slug: params.slug }, { request, timings }, ).catch(() => null) - const headers = { - 'Cache-Control': 'private, max-age=3600', - Vary: 'Cookie', - 'Server-Timing': getServerTimeHeader(timings), - } if (!page) { - const blogRecommendations = await getBlogRecommendations({ - request, - timings, + const [recommendations, suggestions] = await Promise.all([ + getBlogRecommendations({ request, timings }), + getNotFoundSuggestions({ request, pathname, limit: 8 }), + ]) + const data: { + recommendations: Array + possibleMatches?: Array + possibleMatchesQuery?: string + } = { recommendations } + if (suggestions) { + data.possibleMatches = suggestions.matches + data.possibleMatchesQuery = suggestions.query + } + throw json(data, { + status: 404, + headers: { + // Don't cache speculative 404 slugs for long. + 'Cache-Control': 'private, max-age=60', + Vary: 'Cookie', + 'Server-Timing': getServerTimeHeader(timings), + }, }) - throw json({ blogRecommendations }, { status: 404, headers }) } - return json({ page }, { status: 200, headers }) + return json( + { page }, + { + status: 200, + headers: { + 'Cache-Control': 'private, max-age=3600', + Vary: 'Cookie', + 'Server-Timing': getServerTimeHeader(timings), + }, + }, + ) } export const headers: HeadersFunction = reuseUsefulLoaderHeaders @@ -154,7 +179,11 @@ export function ErrorBoundary() { statusHandlers={{ 400: ({ error }) => , 404: ({ error }) => ( - + ), }} /> diff --git a/app/routes/blog_/$slug.tsx b/app/routes/blog_/$slug.tsx index cfa8d2922..216c65c03 100644 --- a/app/routes/blog_/$slug.tsx +++ b/app/routes/blog_/$slug.tsx @@ -27,6 +27,8 @@ import { getBlogRecommendations, getTotalPostReads, } from '#app/utils/blog.server.ts' +import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' +import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts' import { getRankingLeader } from '#app/utils/blog.ts' import { getBlogMdxListItems, getMdxPage } from '#app/utils/mdx.server.ts' import { @@ -63,6 +65,8 @@ export const handle: KCDHandle = { type CatchData = { recommendations: Array + possibleMatches?: Array + possibleMatchesQuery?: string } export async function loader({ request, params }: Route.LoaderArgs) { @@ -81,13 +85,17 @@ export async function loader({ request, params }: Route.LoaderArgs) { if (!page) { // Avoid caching/creating per-slug stats entries for random 404 slugs. (issue #461) - const recommendations = await getBlogRecommendations({ - request, - timings, - limit: 3, - keywords: [], - exclude: [params.slug], - }) + const pathname = new URL(request.url).pathname + const [recommendations, suggestions] = await Promise.all([ + getBlogRecommendations({ + request, + timings, + limit: 3, + keywords: [], + exclude: [params.slug], + }), + getNotFoundSuggestions({ request, pathname, limit: 8 }), + ]) const headers = { // Don't cache speculative 404 slugs for long. 'Cache-Control': 'private, max-age=60', @@ -95,6 +103,10 @@ export async function loader({ request, params }: Route.LoaderArgs) { 'Server-Timing': getServerTimeHeader(timings), } const catchData: CatchData = { recommendations } + if (suggestions) { + catchData.possibleMatches = suggestions.matches + catchData.possibleMatchesQuery = suggestions.query + } throw json(catchData, { status: 404, headers }) } @@ -629,7 +641,11 @@ export function ErrorBoundary() { statusHandlers={{ 400: ({ error }) => , 404: ({ error }) => ( - + ), }} /> diff --git a/app/utils/not-found-matches.ts b/app/utils/not-found-matches.ts new file mode 100644 index 000000000..deaace673 --- /dev/null +++ b/app/utils/not-found-matches.ts @@ -0,0 +1,45 @@ +export type NotFoundMatch = { + url: string + type: string + title: string + summary?: string + imageUrl?: string + imageAlt?: string +} + +function normalizeType(type: string) { + // Keep UI labels simple and consistent. + if (type === 'jsx-page') return 'page' + return type +} + +function getTypePriority(type: string, priorities: ReadonlyArray) { + const normalized = normalizeType(type) + const idx = priorities.indexOf(normalized) + return idx === -1 ? Number.POSITIVE_INFINITY : idx +} + +/** + * Stable sort that keeps semantic ranking within each type group. + */ +export function sortNotFoundMatches( + matches: ReadonlyArray, + { + priorities = ['blog', 'page'], + }: { + priorities?: ReadonlyArray + } = {}, +): Array { + return matches + .map((m, index) => ({ + m: { ...m, type: normalizeType(m.type) }, + index, + priority: getTypePriority(m.type, priorities), + })) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority + return a.index - b.index + }) + .map((x) => x.m) +} + diff --git a/app/utils/not-found-query.ts b/app/utils/not-found-query.ts new file mode 100644 index 000000000..1aa93c62a --- /dev/null +++ b/app/utils/not-found-query.ts @@ -0,0 +1,34 @@ +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** + * Convert a missing pathname into a semantic-search-friendly query string. + * + * Example: `/blog/react-testing-library` -> `blog react testing library` + */ +export function notFoundQueryFromPathname(pathname: string) { + const cleaned = (pathname ?? '').split(/[?#]/)[0] ?? '' + const segments = cleaned.split('/').filter(Boolean) + if (segments.length === 0) return '' + + const words = segments + .map((segment) => { + let text = safeDecodeURIComponent(segment) + text = text.replace(/[-_.]+/g, ' ') + // Split simple camelCase boundaries. + text = text.replace(/([a-z])([A-Z])/g, '$1 $2') + return text + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim() + + // Keep queries reasonably small; embeddings don't benefit from very long URL-ish strings. + return words.length > 120 ? words.slice(0, 120).trim() : words +} + diff --git a/app/utils/not-found-suggestions.server.ts b/app/utils/not-found-suggestions.server.ts new file mode 100644 index 000000000..e9665f6da --- /dev/null +++ b/app/utils/not-found-suggestions.server.ts @@ -0,0 +1,114 @@ +import { notFoundQueryFromPathname } from './not-found-query.ts' +import { sortNotFoundMatches, type NotFoundMatch } from './not-found-matches.ts' +import { + isSemanticSearchConfigured, + semanticSearchKCD, +} from './semantic-search.server.ts' + +function requestWantsHtml(request: Request) { + // Avoid expensive semantic search for asset/API requests. + const accept = request.headers.get('accept') ?? '' + return ( + accept.includes('text/html') || + accept.includes('application/xhtml+xml') + ) +} + +function normalizePathname(pathname: string) { + const cleaned = (pathname.split(/[?#]/)[0] ?? '').trim() + if (!cleaned) return '/' + if (cleaned === '/') return '/' + return cleaned.replace(/\/+$/, '') || '/' +} + +function toUrlKey(url: string) { + // Normalize relative and absolute URLs for dedupe. + try { + const u = new URL(url, 'https://kentcdodds.com') + return `${u.pathname}${u.search}` + } catch { + return url + } +} + +export async function getNotFoundSuggestions({ + request, + pathname, + limit = 8, + topK = 15, + priorities, +}: { + request: Request + pathname?: string + limit?: number + topK?: number + priorities?: ReadonlyArray +}): Promise<{ query: string; matches: Array } | null> { + // Unit tests don't load MSW handlers; avoid real network calls. + if (process.env.NODE_ENV === 'test') return null + if (request.method.toUpperCase() !== 'GET') return null + if (!requestWantsHtml(request)) return null + if (!isSemanticSearchConfigured()) return null + + const resolvedPathname = normalizePathname( + typeof pathname === 'string' && pathname ? pathname : new URL(request.url).pathname, + ) + const query = notFoundQueryFromPathname(resolvedPathname) + if (!query || query.length < 3) return null + + try { + const results = await semanticSearchKCD({ query, topK }) + const byUrl = new Map() + + for (const r of results) { + const url = + typeof r.url === 'string' && r.url.trim() + ? r.url.trim() + : typeof r.id === 'string' && r.id.startsWith('/') + ? r.id + : '' + if (!url) continue + + // Skip suggesting the missing URL itself. + if (normalizePathname(url) === resolvedPathname) continue + + const key = toUrlKey(url) + if (byUrl.has(key)) continue + + byUrl.set(key, { + url, + type: typeof r.type === 'string' && r.type.trim() ? r.type.trim() : 'result', + title: + typeof r.title === 'string' && r.title.trim() + ? r.title.trim() + : url, + summary: + typeof r.summary === 'string' && r.summary.trim() + ? r.summary.trim() + : typeof r.snippet === 'string' && r.snippet.trim() + ? r.snippet.trim() + : undefined, + imageUrl: + typeof r.imageUrl === 'string' && r.imageUrl.trim() + ? r.imageUrl.trim() + : undefined, + imageAlt: + typeof r.imageAlt === 'string' && r.imageAlt.trim() + ? r.imageAlt.trim() + : undefined, + }) + } + + const matches = sortNotFoundMatches([...byUrl.values()], { priorities }).slice( + 0, + Math.max(0, Math.floor(limit)), + ) + + return { query, matches } + } catch (error: unknown) { + // 404 pages should never fail the request because semantic search failed. + console.error('Semantic search failed while rendering 404 suggestions', error) + return null + } +} + From 1bed87bdf49246fcd00d22b30b1e1536e1fe887d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 22:44:13 +0000 Subject: [PATCH 02/10] Fix lint for 404 suggestions Co-authored-by: Kent C. Dodds --- app/components/errors.tsx | 6 ++++-- app/routes/$slug.tsx | 2 +- app/routes/blog_/$slug.tsx | 4 ++-- app/utils/not-found-suggestions.server.ts | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/components/errors.tsx b/app/components/errors.tsx index 29478971c..80050248b 100644 --- a/app/components/errors.tsx +++ b/app/components/errors.tsx @@ -3,9 +3,9 @@ import errorStack from 'error-stack-parser' import * as React from 'react' 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 { getErrorMessage } from '#app/utils/misc.ts' import { ArrowLink } from './arrow-button.tsx' import { Grid } from './grid.tsx' import { Facepalm, Grimmacing, MissingSomething } from './kifs.tsx' @@ -246,7 +246,9 @@ function FourOhFour({ if (!effectiveQuery) return if (requestedQueryRef.current === effectiveQuery) return requestedQueryRef.current = effectiveQuery - fetcher.load(`/resources/search?query=${encodeURIComponent(effectiveQuery)}`) + void fetcher.load( + `/resources/search?query=${encodeURIComponent(effectiveQuery)}`, + ) }, [effectiveQuery, fetcher, possibleMatchesProp]) const fetchedMatches = React.useMemo(() => { diff --git a/app/routes/$slug.tsx b/app/routes/$slug.tsx index 37bb4b829..166d79631 100644 --- a/app/routes/$slug.tsx +++ b/app/routes/$slug.tsx @@ -18,8 +18,8 @@ import { mdxPageMeta, useMdxComponent, } from '#app/utils/mdx.tsx' -import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' import { requireValidSlug, reuseUsefulLoaderHeaders } from '#app/utils/misc.ts' +import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts' import { getServerTimeHeader } from '#app/utils/timing.server.ts' import { type Route } from './+types/$slug' diff --git a/app/routes/blog_/$slug.tsx b/app/routes/blog_/$slug.tsx index 216c65c03..1d8b6f298 100644 --- a/app/routes/blog_/$slug.tsx +++ b/app/routes/blog_/$slug.tsx @@ -27,8 +27,6 @@ import { getBlogRecommendations, getTotalPostReads, } from '#app/utils/blog.server.ts' -import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' -import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts' import { getRankingLeader } from '#app/utils/blog.ts' import { getBlogMdxListItems, getMdxPage } from '#app/utils/mdx.server.ts' import { @@ -42,6 +40,8 @@ import { requireValidSlug, reuseUsefulLoaderHeaders, } from '#app/utils/misc.ts' +import { type NotFoundMatch } from '#app/utils/not-found-matches.ts' +import { getNotFoundSuggestions } from '#app/utils/not-found-suggestions.server.ts' import { prisma } from '#app/utils/prisma.server.ts' import { getUser } from '#app/utils/session.server.ts' import { teamEmoji, useTeam } from '#app/utils/team-provider.tsx' diff --git a/app/utils/not-found-suggestions.server.ts b/app/utils/not-found-suggestions.server.ts index e9665f6da..315dc037e 100644 --- a/app/utils/not-found-suggestions.server.ts +++ b/app/utils/not-found-suggestions.server.ts @@ -1,5 +1,5 @@ -import { notFoundQueryFromPathname } from './not-found-query.ts' import { sortNotFoundMatches, type NotFoundMatch } from './not-found-matches.ts' +import { notFoundQueryFromPathname } from './not-found-query.ts' import { isSemanticSearchConfigured, semanticSearchKCD, From d8d15d17a1dff1b4f6e0a665ae3ff2e80aac314a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 23:31:05 +0000 Subject: [PATCH 03/10] Replace 404 Go home with matches link Co-authored-by: Kent C. Dodds --- app/components/errors.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/components/errors.tsx b/app/components/errors.tsx index 80050248b..86dbf443f 100644 --- a/app/components/errors.tsx +++ b/app/components/errors.tsx @@ -103,6 +103,12 @@ function ErrorPage({ ) : null} + {possibleMatchesQuery && !possibleMatches?.length ? ( + // Ensure in-page links to `#possible-matches` have a target even + // before semantic results load. +
+ ) : null} + {possibleMatches?.length ? ( , - action: Go home, + action: Possible matches, }} /> ) From fecc65dc5a758b2cb6f034b3050870c9f6d1a720 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 23:42:05 +0000 Subject: [PATCH 04/10] Fix 404 suggestions for updated semantic search Co-authored-by: Kent C. Dodds --- app/utils/not-found-suggestions.server.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/utils/not-found-suggestions.server.ts b/app/utils/not-found-suggestions.server.ts index 315dc037e..4caf4cf98 100644 --- a/app/utils/not-found-suggestions.server.ts +++ b/app/utils/not-found-suggestions.server.ts @@ -1,9 +1,6 @@ import { sortNotFoundMatches, type NotFoundMatch } from './not-found-matches.ts' import { notFoundQueryFromPathname } from './not-found-query.ts' -import { - isSemanticSearchConfigured, - semanticSearchKCD, -} from './semantic-search.server.ts' +import { semanticSearchKCD } from './semantic-search.server.ts' function requestWantsHtml(request: Request) { // Avoid expensive semantic search for asset/API requests. @@ -48,7 +45,6 @@ export async function getNotFoundSuggestions({ if (process.env.NODE_ENV === 'test') return null if (request.method.toUpperCase() !== 'GET') return null if (!requestWantsHtml(request)) return null - if (!isSemanticSearchConfigured()) return null const resolvedPathname = normalizePathname( typeof pathname === 'string' && pathname ? pathname : new URL(request.url).pathname, From c23a5bc19d01f6569a59d79e991ca77529c31eb6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Feb 2026 02:54:48 +0000 Subject: [PATCH 05/10] Harden 404 match URLs and fallback Co-authored-by: Kent C. Dodds --- app/components/errors.tsx | 31 ++++++++++++++++++++--- app/utils/not-found-suggestions.server.ts | 18 ++++++++++++- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/app/components/errors.tsx b/app/components/errors.tsx index 86dbf443f..6fe19ab0c 100644 --- a/app/components/errors.tsx +++ b/app/components/errors.tsx @@ -209,7 +209,8 @@ function PossibleMatchesSection({ function asNotFoundMatchFromResourceSearch(value: unknown): NotFoundMatch | null { if (!value || typeof value !== 'object') return null const v = value as Record - const url = typeof v.url === 'string' ? v.url.trim() : '' + const url = + typeof v.url === 'string' ? normalizeNotFoundUrl(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() : '' @@ -226,6 +227,23 @@ function asNotFoundMatchFromResourceSearch(value: unknown): NotFoundMatch | null } } +function normalizeNotFoundUrl(rawUrl: string) { + const url = rawUrl.trim() + if (!url) return '' + // Only allow internal app paths. This also keeps client/server rendering consistent + // when `/resources/search` returns absolute URLs. + if (url.startsWith('/')) return url + if (/^https?:\/\//i.test(url)) { + try { + const u = new URL(url) + return `${u.pathname}${u.search}${u.hash}` + } catch { + return '' + } + } + return '' +} + function FourOhFour({ articles, possibleMatches: possibleMatchesProp, @@ -248,7 +266,8 @@ function FourOhFour({ const requestedQueryRef = React.useRef('') React.useEffect(() => { - if (possibleMatchesProp != null) return + // Treat `[]` as "no server data" so we still allow client fallback. + if (Array.isArray(possibleMatchesProp) && possibleMatchesProp.length > 0) return if (!effectiveQuery) return if (requestedQueryRef.current === effectiveQuery) return requestedQueryRef.current = effectiveQuery @@ -265,7 +284,11 @@ function FourOhFour({ .filter((v): v is NotFoundMatch => Boolean(v)) }, [fetcher.data]) - const possibleMatches = possibleMatchesProp ?? fetchedMatches + const possibleMatches = + Array.isArray(possibleMatchesProp) && possibleMatchesProp.length > 0 + ? possibleMatchesProp + : fetchedMatches + const heroActionTo = effectiveQuery ? '#possible-matches' : '/search' return ( , - action: Possible matches, + action: Possible matches, }} /> ) diff --git a/app/utils/not-found-suggestions.server.ts b/app/utils/not-found-suggestions.server.ts index 4caf4cf98..befc22b34 100644 --- a/app/utils/not-found-suggestions.server.ts +++ b/app/utils/not-found-suggestions.server.ts @@ -28,6 +28,21 @@ function toUrlKey(url: string) { } } +function normalizeNotFoundUrl(rawUrl: string) { + const url = rawUrl.trim() + if (!url) return '' + if (url.startsWith('/')) return url + if (/^https?:\/\//i.test(url)) { + try { + const u = new URL(url) + return `${u.pathname}${u.search}${u.hash}` + } catch { + return '' + } + } + return '' +} + export async function getNotFoundSuggestions({ request, pathname, @@ -57,12 +72,13 @@ export async function getNotFoundSuggestions({ const byUrl = new Map() for (const r of results) { - const url = + const rawUrl = typeof r.url === 'string' && r.url.trim() ? r.url.trim() : typeof r.id === 'string' && r.id.startsWith('/') ? r.id : '' + const url = rawUrl ? normalizeNotFoundUrl(rawUrl) : '' if (!url) continue // Skip suggesting the missing URL itself. From 16ba22e68cea874cd218247aceaa49c44b4a9aac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Feb 2026 03:07:26 +0000 Subject: [PATCH 06/10] Avoid 404 heroProps mutation and bound suggestion latency Co-authored-by: Kent C. Dodds --- app/components/errors.tsx | 18 ++++++------------ app/utils/not-found-suggestions.server.ts | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/components/errors.tsx b/app/components/errors.tsx index 6fe19ab0c..3a6914ab3 100644 --- a/app/components/errors.tsx +++ b/app/components/errors.tsx @@ -69,17 +69,11 @@ function ErrorPage({ possibleMatchesQuery?: string heroProps: HeroSectionProps }) { - 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!', - }) - } + const resolvedHeroProps: HeroSectionProps = possibleMatches?.length + ? { ...heroProps, arrowUrl: '#possible-matches', arrowLabel: 'Possible matches' } + : articles?.length + ? { ...heroProps, arrowUrl: '#articles', arrowLabel: 'But wait, there is more!' } + : heroProps return ( <>