Skip to content

Commit 3cd0aa5

Browse files
committed
fix(showcase/shell-docs): search modal types, load error, cleanup, dedupe, split loading states
- Replace useState<any> for registryData with typed Registry | null - Surface registry-load failures: .catch logs + inline "Search index failed to load" banner - Track the setTimeout focus id and clear it in effect cleanup - Let static search-index matches render immediately; show a '[loading...]' hint until registry.json resolves; show 'no results' only after loading completes - Use a ref for selectedIndex so Enter never reads a stale value after reset-on-input - Dedupe results by type+href before slicing to 12 (stable key becomes type-href) - Route navigation through a shared helper that detects external URLs (http(s)://, //) and uses window.location.assign for them, router.push otherwise - Warn once in dev when an integration has no description so it gets fixed upstream
1 parent 0f46be9 commit 3cd0aa5

1 file changed

Lines changed: 141 additions & 56 deletions

File tree

showcase/shell-docs/src/components/search-modal.tsx

Lines changed: 141 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

3-
import { useState, useEffect, useRef, useMemo } from "react";
3+
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
44
import { useRouter } from "next/navigation";
55
import searchIndex from "@/data/search-index.json";
6+
import type { Registry } from "@/lib/registry";
67

78
interface SearchResult {
89
type: "integration" | "feature" | "demo" | "page" | "reference" | "ag-ui";
@@ -12,16 +13,58 @@ interface SearchResult {
1213
href: string;
1314
}
1415

16+
function isExternalHref(href: string): boolean {
17+
return /^(https?:)?\/\//i.test(href);
18+
}
19+
20+
function dedupeResults(items: SearchResult[]): SearchResult[] {
21+
const seen = new Set<string>();
22+
const out: SearchResult[] = [];
23+
for (const item of items) {
24+
const key = `${item.type}::${item.href}`;
25+
if (seen.has(key)) continue;
26+
seen.add(key);
27+
out.push(item);
28+
}
29+
return out;
30+
}
31+
1532
export function SearchModal({ onClose }: { onClose: () => void }) {
1633
const [query, setQuery] = useState("");
1734
const [selectedIndex, setSelectedIndex] = useState(0);
18-
const [registryData, setRegistryData] = useState<any>(null);
35+
const [registryData, setRegistryData] = useState<Registry | null>(null);
36+
const [registryError, setRegistryError] = useState(false);
1937
const inputRef = useRef<HTMLInputElement>(null);
38+
const selectedIndexRef = useRef(0);
2039
const router = useRouter();
2140

41+
// Keep a ref in sync with selectedIndex so the Enter handler never reads
42+
// a stale closure value (reset-on-input + key-handler race).
2243
useEffect(() => {
23-
setTimeout(() => inputRef.current?.focus(), 50);
24-
import("@/data/registry.json").then((mod) => setRegistryData(mod.default));
44+
selectedIndexRef.current = selectedIndex;
45+
}, [selectedIndex]);
46+
47+
useEffect(() => {
48+
const focusId = window.setTimeout(
49+
() => inputRef.current?.focus(),
50+
50,
51+
);
52+
let cancelled = false;
53+
import("@/data/registry.json")
54+
.then((mod) => {
55+
if (!cancelled) setRegistryData(mod.default as Registry);
56+
})
57+
.catch((err) => {
58+
if (!cancelled) {
59+
// eslint-disable-next-line no-console
60+
console.error("[search-modal] failed to load registry", err);
61+
setRegistryError(true);
62+
}
63+
});
64+
return () => {
65+
cancelled = true;
66+
window.clearTimeout(focusId);
67+
};
2568
}, []);
2669

2770
useEffect(() => {
@@ -33,11 +76,13 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
3376
}, [onClose]);
3477

3578
const results = useMemo(() => {
36-
if (!query.trim() || !registryData) return [];
79+
if (!query.trim()) return [];
3780

3881
const q = query.toLowerCase();
3982
const items: SearchResult[] = [];
4083

84+
// Static search index is available immediately — search it even before
85+
// the dynamic registry.json has resolved.
4186
// Auto-generated by: npx tsx showcase/scripts/generate-search-index.ts
4287
const pages = searchIndex as SearchResult[];
4388

@@ -51,64 +96,95 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
5196
}
5297
}
5398

54-
for (const i of registryData.integrations || []) {
55-
if (
56-
i.name.toLowerCase().includes(q) ||
57-
i.description?.toLowerCase().includes(q)
58-
) {
59-
items.push({
60-
type: "integration",
61-
title: i.name,
62-
subtitle: (i.description || "").slice(0, 80),
63-
href: `/integrations/${i.slug}`,
64-
});
65-
}
66-
for (const d of i.demos || []) {
99+
if (registryData) {
100+
for (const i of registryData.integrations || []) {
67101
if (
68-
d.name.toLowerCase().includes(q) ||
69-
d.description?.toLowerCase().includes(q) ||
70-
d.tags?.some((t: string) => t.toLowerCase().includes(q))
102+
process.env.NODE_ENV !== "production" &&
103+
(!i.description || i.description.trim() === "")
104+
) {
105+
// eslint-disable-next-line no-console
106+
console.warn(
107+
`[search-modal] integration "${i.slug}" has no description — fix upstream in registry`,
108+
);
109+
}
110+
if (
111+
i.name.toLowerCase().includes(q) ||
112+
i.description?.toLowerCase().includes(q)
71113
) {
72114
items.push({
73-
type: "demo",
74-
title: d.name,
75-
subtitle: `${i.name} · ${d.description}`,
76-
href: `/integrations/${i.slug}/${d.id}`,
115+
type: "integration",
116+
title: i.name,
117+
subtitle: (i.description || "").slice(0, 80),
118+
href: `/integrations/${i.slug}`,
77119
});
78120
}
121+
for (const d of i.demos || []) {
122+
if (
123+
d.name.toLowerCase().includes(q) ||
124+
d.description?.toLowerCase().includes(q) ||
125+
d.tags?.some((t: string) => t.toLowerCase().includes(q))
126+
) {
127+
items.push({
128+
type: "demo",
129+
title: d.name,
130+
subtitle: `${i.name} · ${d.description}`,
131+
href: `/integrations/${i.slug}/${d.id}`,
132+
});
133+
}
134+
}
79135
}
80-
}
81136

82-
for (const f of registryData.feature_registry?.features || []) {
83-
if (
84-
f.name.toLowerCase().includes(q) ||
85-
f.description?.toLowerCase().includes(q)
86-
) {
87-
items.push({
88-
type: "feature",
89-
title: f.name,
90-
subtitle: f.description,
91-
href: "/matrix",
92-
});
137+
for (const f of registryData.feature_registry?.features || []) {
138+
if (
139+
f.name.toLowerCase().includes(q) ||
140+
f.description?.toLowerCase().includes(q)
141+
) {
142+
items.push({
143+
type: "feature",
144+
title: f.name,
145+
subtitle: f.description,
146+
href: "/matrix",
147+
});
148+
}
93149
}
94150
}
95151

96-
return items.slice(0, 12);
152+
return dedupeResults(items).slice(0, 12);
97153
}, [query, registryData]);
98154

99-
function onInputKeyDown(e: React.KeyboardEvent) {
100-
if (e.key === "ArrowDown") {
101-
e.preventDefault();
102-
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
103-
} else if (e.key === "ArrowUp") {
104-
e.preventDefault();
105-
setSelectedIndex((i) => Math.max(i - 1, 0));
106-
} else if (e.key === "Enter" && results[selectedIndex]) {
107-
e.preventDefault();
108-
router.push(results[selectedIndex].href);
155+
const navigateTo = useCallback(
156+
(href: string) => {
157+
if (isExternalHref(href)) {
158+
window.location.assign(href);
159+
} else {
160+
router.push(href);
161+
}
109162
onClose();
110-
}
111-
}
163+
},
164+
[router, onClose],
165+
);
166+
167+
const onInputKeyDown = useCallback(
168+
(e: React.KeyboardEvent) => {
169+
if (e.key === "ArrowDown") {
170+
e.preventDefault();
171+
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
172+
} else if (e.key === "ArrowUp") {
173+
e.preventDefault();
174+
setSelectedIndex((i) => Math.max(i - 1, 0));
175+
} else if (e.key === "Enter") {
176+
const idx = selectedIndexRef.current;
177+
const chosen = results[idx];
178+
if (chosen) {
179+
e.preventDefault();
180+
navigateTo(chosen.href);
181+
}
182+
}
183+
},
184+
[results, navigateTo],
185+
);
186+
187+
const registryLoading = !registryData && !registryError;
112188

113189
return (
114190
<>
@@ -137,20 +213,29 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
137213
</kbd>
138214
</div>
139215

216+
{registryError && (
217+
<div className="px-5 py-2.5 text-[12px] text-[var(--text-muted)] border-b border-[var(--border)] bg-[var(--bg-elevated)]">
218+
Search index failed to load. Try refresh.
219+
</div>
220+
)}
221+
222+
{query.trim() && registryLoading && (
223+
<div className="px-5 py-2 text-[11px] text-[var(--text-faint)] border-b border-[var(--border)]">
224+
[loading integrations & features…]
225+
</div>
226+
)}
227+
140228
{results.length > 0 && (
141229
<div className="max-h-[320px] overflow-y-auto py-2">
142230
{results.map((r, idx) => (
143231
<button
144-
key={`${r.href}-${idx}`}
232+
key={`${r.type}-${r.href}`}
145233
className={`w-full text-left px-5 py-3 flex items-center gap-3 transition-colors ${
146234
idx === selectedIndex
147235
? "bg-[var(--bg-elevated)]"
148236
: "hover:bg-[var(--bg-hover)]"
149237
}`}
150-
onClick={() => {
151-
router.push(r.href);
152-
onClose();
153-
}}
238+
onClick={() => navigateTo(r.href)}
154239
onMouseEnter={() => setSelectedIndex(idx)}
155240
>
156241
<span className="text-[10px] font-mono text-[var(--text-faint)] uppercase w-14 shrink-0">
@@ -174,7 +259,7 @@ export function SearchModal({ onClose }: { onClose: () => void }) {
174259
</div>
175260
)}
176261

177-
{query.trim() && results.length === 0 && (
262+
{query.trim() && results.length === 0 && !registryLoading && (
178263
<div className="px-5 py-8 text-center text-[13px] text-[var(--text-muted)]">
179264
No results for &ldquo;{query}&rdquo;
180265
</div>

0 commit comments

Comments
 (0)