web: fix search debounce and client/server boundary

This commit is contained in:
2026-05-25 23:16:52 +02:00
parent 433c88c921
commit b5c9188488
3 changed files with 67 additions and 65 deletions
+7 -7
View File
@@ -1,10 +1,10 @@
import { graphql } from "@/gql";
import { getClient } from "@/app/client"; import { getClient } from "@/app/client";
import { import {
SearchContainer,
type SearchResult, type SearchResult,
} from "@/components/search/SearchContainer"; SearchResults,
import { Suspense } from "react"; } from "@/components/search/SearchResults";
import { SearchShell } from "@/components/search/SearchShell";
import { graphql } from "@/gql";
// TODO: seo metadata? // TODO: seo metadata?
@@ -69,9 +69,9 @@ export default async function Page({
return ( return (
<main className="site-main" id="main"> <main className="site-main" id="main">
<Suspense key={query}> <SearchShell initialQuery={query ?? ""}>
<SearchContainer query={query ?? ""} results={results} /> {query ? <SearchResults results={results} /> : null}
</Suspense> </SearchShell>
</main> </main>
); );
} }
@@ -1,18 +1,8 @@
"use client"; import { ImageFragmentDefinition, stripHtml } from "@/lib/common";
import { useEffect, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import { PageHeader } from "../general/PageHeader";
import { useRouter } from "next/navigation";
import {
ImageFragmentDefinition,
getSearchPath,
stripHtml,
} from "@/lib/common";
import { formatDate, formatOccurrenceMonths } from "@/lib/date"; import { formatDate, formatOccurrenceMonths } from "@/lib/date";
import { unmaskFragment } from "@/gql"; import { unmaskFragment } from "@/gql";
import type { ImageFragment, SearchQuery } from "@/gql/graphql"; import type { ImageFragment, SearchQuery } from "@/gql/graphql";
import styles from "./searchContainer.module.scss"; import styles from "./searchContainer.module.scss";
import { Icon } from "../general/Icon";
import { Image } from "../general/Image"; import { Image } from "../general/Image";
import Link from "next/link"; import Link from "next/link";
@@ -29,57 +19,12 @@ const PAGE_TYPES = {
type SupportedTypename = keyof typeof PAGE_TYPES; type SupportedTypename = keyof typeof PAGE_TYPES;
type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>; type SupportedResult = Extract<SearchResult, { __typename: SupportedTypename }>;
export function SearchContainer({
query,
results,
}: {
query: string;
results: SearchResult[];
}) {
const { replace } = useRouter();
const [inputValue, setInputValue] = useState(query);
useEffect(() => {
setInputValue(query);
}, [query]);
const pushQuery = useDebouncedCallback((next: string) => {
replace(getSearchPath(next));
}, 500);
return (
<div className={styles.searchContainer}>
<PageHeader heading="Søk" />
<div className={styles.searchField}>
<input
name="query"
type="text"
autoFocus
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
pushQuery(e.target.value);
}}
/>
<div className={styles.searchIcon}>
<Icon type="search" />
</div>
</div>
{query && <SearchResults results={results} />}
</div>
);
}
function capitalizeFirstLetter(s: string) { function capitalizeFirstLetter(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
function isSupported(result: SearchResult): result is SupportedResult { function isSupported(result: SearchResult): result is SupportedResult {
return ( return result.__typename in PAGE_TYPES && "id" in result && !!result.id;
result.__typename in PAGE_TYPES &&
"id" in result &&
!!result.id
);
} }
function getResultType(result: SupportedResult): string { function getResultType(result: SupportedResult): string {
@@ -131,7 +76,7 @@ function getResultSnippet(result: SupportedResult): string | null {
} }
} }
function SearchResults({ results }: { results: SearchResult[] }) { export function SearchResults({ results }: { results: SearchResult[] }) {
if (!results.length) { if (!results.length) {
return <div className={styles.noResults}>Ingen resultater</div>; return <div className={styles.noResults}>Ingen resultater</div>;
} }
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef, useState, useTransition, type ReactNode } from "react";
import { useDebouncedCallback } from "use-debounce";
import { useRouter } from "next/navigation";
import { PageHeader } from "../general/PageHeader";
import { getSearchPath } from "@/lib/common";
import styles from "./searchContainer.module.scss";
import { Icon } from "../general/Icon";
export function SearchShell({
initialQuery,
children,
}: {
initialQuery: string;
children: ReactNode;
}) {
const { replace } = useRouter();
const [inputValue, setInputValue] = useState(initialQuery);
const [, startTransition] = useTransition();
const lastPushedRef = useRef(initialQuery);
const pushQuery = useDebouncedCallback((next: string) => {
lastPushedRef.current = next;
startTransition(() => {
replace(getSearchPath(next));
});
}, 300);
useEffect(() => {
if (initialQuery !== lastPushedRef.current) {
lastPushedRef.current = initialQuery;
setInputValue(initialQuery);
}
}, [initialQuery]);
return (
<div className={styles.searchContainer}>
<PageHeader heading="Søk" />
<div className={styles.searchField}>
<input
name="query"
type="text"
autoFocus
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
pushQuery(e.target.value);
}}
/>
<div className={styles.searchIcon}>
<Icon type="search" />
</div>
</div>
{children}
</div>
);
}