Skip to content

Commit fc25cfb

Browse files
authored
fix(docs): fix Core Web Vitals regressions on docs.sim.ai (#5630)
* fix(docs): fix Core Web Vitals regressions on docs.sim.ai Empirically measured under real trace-based (devtools) CPU/network throttling against the live site: mobile Performance 59, LCP 9.2s (TTFB 745ms + 8.4s element render delay). - sidebar-components.tsx / [lang]/layout.tsx: the docs sidebar renders every page in the doc tree as a link at once. Next's default viewport-prefetch fired an RSC payload fetch for every one of them on initial load - dozens of concurrent requests competing with the page's own content for bandwidth. Wired fumadocs' documented `sidebar.prefetch` option through to the custom SidebarItem/SidebarFolder components (which were bypassing it entirely, using next/link directly with no prefetch prop) via the `useSidebar()` context hook. - video.tsx: `autoPlay` forces browsers to fetch the full video file immediately on mount regardless of `preload`. Gated actual src loading behind an IntersectionObserver so a page with several of these doesn't pull down every video up front (5MB across 3 requests, in this case). Single shared component - fixes every doc page that embeds one. - proxy.ts: the i18n middleware matcher excluded favicon/robots.txt/etc but not `icon.svg`, so every request for it got routed through i18n negotiation instead of served as a static file, 404ing in production. - next.config.ts: enable productionBrowserSourceMaps - safe since this repo's source is already fully public, real debuggability benefit, zero performance cost. - shiki 4.0.0 -> 4.3.1 (verified: syntax highlighting still renders correctly). Attempted a coordinated fumadocs-core/ui/mdx/openapi upgrade to latest; fumadocs-openapi's v11 factory function became client-only (breaking change beyond its declared peer deps, requiring a component-boundary restructure), so only the safe, verified, docs-exclusive bumps (fumadocs-core/ui/mdx, shiki) are included here - the openapi major bump needs its own dedicated migration PR. Verified via a real production build (dummy env, all 3974 pages including API reference render/build cleanly) and a clean (non-stale) local server: Performance 59 -> 71 measured under real devtools throttling, RSC prefetch requests 63 -> 11, video requests/bytes 3/5MB -> 0. A pre-existing React hydration warning (#418) was found and confirmed present on live production before any of these changes, unrelated to this diff - documented, not blocking. * fix(docs): fall back to eager video loading without IntersectionObserver The lazy-load gate from the previous commit threw before isInView could ever become true in environments lacking IntersectionObserver (older browsers, some embedded webviews), leaving videos permanently source-less instead of falling back to eager loading. * chore(docs): drop non-TSDoc inline comments Repo convention is TSDoc-only, no plain // comments. * fix(docs): accessibility and SEO defects across the docs app Audited with parallel subagents against the accessibility and SEO skill checklists, each fix verified by reading the actual code (not assumed): Accessibility: - lightbox.tsx: focus was never captured/restored on close, and Tab escaped the modal to the page behind it (no focus trap on the single focusable element) - heading.tsx: the per-heading copy-link icon only appeared on hover, invisible to keyboard-only navigation (added peer-focus-visible) - navbar.tsx: active nav tab had no aria-current - response-section.tsx: the status-code dropdown had no aria-haspopup/aria-expanded/role, and no Escape-to-close - workflow-preview.tsx: same focus-trap gap as lightbox.tsx on the expanded-canvas modal SEO: - page.tsx: generateMetadata's hreflang/canonical URLs used a naive String.replace to strip the locale prefix, which also matched "/en" inside unrelated slugs (platform/enterprise, integrations/enrich, platform/self-hosting/environment-variables), corrupting those pages' canonical and alternate-language URLs. Replaced with a prefix-only strip. - structured-data.tsx: the SoftwareApplication JSON-LD block compared url === baseUrl (no trailing slash) against the homepage's actual url (always has a trailing slash), so the condition was always false and this structured data never rendered anywhere, including the homepage. - structured-data.tsx: "Mothership" in the indexed featureList violated the constitution's required language (the agent is "Sim", the surface is "Chat") - this ships in JSON-LD search engines parse. * fix(docs): defer the Ask Sim chat widget's heavy deps until opened The chat panel (useChat from @ai-sdk/react, Streamdown + its CSS) was mounted unconditionally in the root layout on every single page, so its full weight loaded and executed even though the widget starts closed on every page view. Traced via the LCP breakdown insight under real devtools CPU/network throttling: the LCP text element (the intro paragraph) had a ~8s element render delay despite a ~13ms TTFB, and bootup-time attributed ~4.3s of scripting time to a single chunk containing React/ReactDOM's own runtime plus this widget's eagerly-bundled dependencies. Split into a lightweight ask-ai.tsx (just the toggle button + open state) and ask-ai-panel.tsx (the actual chat UI, useChat, Streamdown), loaded via next/dynamic(..., { ssr: false }) only when the user opens the widget. Verified: the panel's chunk now has zero network requests on initial page load. Measured (mobile, devtools throttling, /introduction): - Performance: 69 -> 75 - LCP: 8.0s -> 6.4s - TBT: 260ms -> 130ms The remaining ~6.4s LCP delay traces to the same shared chunk, now identified as core React/ReactDOM hydration cost for this page's sidebar/TOC/breadcrumb tree rather than an isolated bug - a real, larger initiative (hydration architecture, not a surgical fix), documented here rather than rushed. * fix(docs): preserve Ask Sim chat state across close/reopen The panel split unmounted AskAIPanel entirely on close, discarding useChat's message state - reopening always started an empty conversation, unlike the original single-component layout where useChat lived in a component that never unmounted. Fixed by keeping the panel mounted (via a hasOpened flag that never resets) once first opened, and having the panel itself return null when closed rather than being conditionally removed from the tree by its parent - hooks still run every render, so useChat's state persists across visibility toggles. The dynamic import still only fires on the first open, so the initial-load win is unchanged. Verified via a real click-through (open, type, close, reopen): input persists correctly, and the panel chunk still has zero network requests on initial page load. Performance unchanged at 75. * chore(docs): lint fixes (import order, formatting) * fix(docs): fill the Ask Sim UI gap while the panel chunk loads handleOpen set open=true synchronously, hiding the trigger button before the dynamically imported panel had a chance to render anything (next/dynamic renders null by default with no loading option) - on a slow connection neither the button nor the panel was visible. Added a loading fallback in the same fixed position so there's no gap between the button disappearing and the real panel appearing.
1 parent ef7c8e2 commit fc25cfb

16 files changed

Lines changed: 373 additions & 281 deletions

File tree

apps/docs/app/[lang]/[[...slug]]/page.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ function resolveLangAndSlug(params: { slug?: string[]; lang: string }) {
3939
return { lang, slug }
4040
}
4141

42+
/**
43+
* Strips a leading `/{lang}` path segment from a page URL. Unlike a naive
44+
* `String.replace`, this only removes the locale when it is actually the
45+
* first path segment — a plain substring replace would also match `/en`
46+
* inside unrelated slugs (e.g. `/platform/enterprise`, `/integrations/enrich`,
47+
* `/platform/self-hosting/environment-variables`), corrupting canonical and
48+
* hreflang URLs for those pages.
49+
*/
50+
function stripLocalePrefix(url: string, lang: string): string {
51+
const prefix = `/${lang}`
52+
if (url === prefix) return ''
53+
if (url.startsWith(`${prefix}/`)) return url.slice(prefix.length)
54+
return url
55+
}
56+
4257
const APIPage = createAPIPage(openapi, {
4358
playground: { enabled: false },
4459
content: {
@@ -342,13 +357,13 @@ export async function generateMetadata(props: {
342357
alternates: {
343358
canonical: fullUrl,
344359
languages: {
345-
'x-default': `${BASE_URL}${page.url.replace(`/${lang}`, '')}`,
346-
en: `${BASE_URL}${page.url.replace(`/${lang}`, '')}`,
347-
es: `${BASE_URL}/es${page.url.replace(`/${lang}`, '')}`,
348-
fr: `${BASE_URL}/fr${page.url.replace(`/${lang}`, '')}`,
349-
de: `${BASE_URL}/de${page.url.replace(`/${lang}`, '')}`,
350-
ja: `${BASE_URL}/ja${page.url.replace(`/${lang}`, '')}`,
351-
zh: `${BASE_URL}/zh${page.url.replace(`/${lang}`, '')}`,
360+
'x-default': `${BASE_URL}${stripLocalePrefix(page.url, lang)}`,
361+
en: `${BASE_URL}${stripLocalePrefix(page.url, lang)}`,
362+
es: `${BASE_URL}/es${stripLocalePrefix(page.url, lang)}`,
363+
fr: `${BASE_URL}/fr${stripLocalePrefix(page.url, lang)}`,
364+
de: `${BASE_URL}/de${stripLocalePrefix(page.url, lang)}`,
365+
ja: `${BASE_URL}/ja${stripLocalePrefix(page.url, lang)}`,
366+
zh: `${BASE_URL}/zh${stripLocalePrefix(page.url, lang)}`,
352367
},
353368
},
354369
}

apps/docs/app/[lang]/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export default async function Layout({ children, params }: LayoutProps) {
110110
collapsible: false,
111111
footer: null,
112112
banner: null,
113+
prefetch: false,
113114
components: {
114115
Item: SidebarItem,
115116
Folder: SidebarFolder,
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
'use client'
2+
3+
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
4+
import { useChat } from '@ai-sdk/react'
5+
import { DefaultChatTransport } from 'ai'
6+
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
7+
import { Streamdown } from 'streamdown'
8+
import { cn } from '@/lib/utils'
9+
import 'streamdown/styles.css'
10+
11+
interface DocSource {
12+
title: string
13+
url: string
14+
}
15+
16+
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
17+
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
18+
const seen = new Set<string>()
19+
const sources: DocSource[] = []
20+
21+
for (const part of parts) {
22+
if (part.type !== 'tool-searchDocs') continue
23+
const output = (part as { output?: unknown }).output
24+
if (!Array.isArray(output)) continue
25+
for (const item of output as DocSource[]) {
26+
if (!item?.url || seen.has(item.url)) continue
27+
seen.add(item.url)
28+
sources.push({ title: item.title, url: item.url })
29+
}
30+
}
31+
32+
return sources
33+
}
34+
35+
/** Concatenate the streamed text parts of a message. */
36+
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
37+
return parts
38+
.filter((part) => part.type === 'text')
39+
.map((part) => (part as unknown as { text: string }).text)
40+
.join('')
41+
}
42+
43+
interface AskAIPanelProps {
44+
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
45+
locale: string
46+
open: boolean
47+
onClose: () => void
48+
}
49+
50+
export function AskAIPanel({ locale, open, onClose }: AskAIPanelProps) {
51+
const [input, setInput] = useState('')
52+
const scrollRef = useRef<HTMLDivElement>(null)
53+
const textareaRef = useRef<HTMLTextAreaElement>(null)
54+
55+
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
56+
57+
const { messages, sendMessage, status, stop, error } = useChat({ transport })
58+
59+
const isBusy = status === 'submitted' || status === 'streaming'
60+
61+
const handleClose = () => {
62+
stop()
63+
onClose()
64+
}
65+
66+
useEffect(() => {
67+
if (!open) return
68+
textareaRef.current?.focus()
69+
const handleKeyDown = (event: KeyboardEvent) => {
70+
if (event.key === 'Escape') {
71+
handleClose()
72+
}
73+
}
74+
document.addEventListener('keydown', handleKeyDown)
75+
return () => document.removeEventListener('keydown', handleKeyDown)
76+
}, [open])
77+
78+
useEffect(() => {
79+
if (!open) return
80+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
81+
}, [open])
82+
83+
useEffect(() => {
84+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
85+
}, [messages])
86+
87+
const handleSubmit = (event: FormEvent) => {
88+
event.preventDefault()
89+
const text = input.trim()
90+
if (!text || isBusy) return
91+
sendMessage({ text }, { body: { locale } })
92+
setInput('')
93+
}
94+
95+
if (!open) return null
96+
97+
return (
98+
<div
99+
role='dialog'
100+
aria-label='Ask Sim'
101+
className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'
102+
>
103+
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
104+
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
105+
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
106+
Ask Sim
107+
</span>
108+
<button
109+
type='button'
110+
aria-label='Close'
111+
onClick={handleClose}
112+
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
113+
>
114+
<X className='size-[16px]' />
115+
</button>
116+
</div>
117+
118+
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
119+
{messages.length === 0 && (
120+
<p className='text-[var(--text-muted)] text-sm'>
121+
Ask anything about building, deploying, and managing AI agents in Sim.
122+
</p>
123+
)}
124+
125+
{messages.map((message, index) => {
126+
const text = getText(message.parts)
127+
const isStreaming = isBusy && index === messages.length - 1
128+
const sources = message.role === 'assistant' ? getSources(message.parts) : []
129+
return (
130+
<div
131+
key={message.id}
132+
className={cn(
133+
'flex flex-col gap-1.5',
134+
message.role === 'user' ? 'items-end' : 'items-start'
135+
)}
136+
>
137+
{message.role === 'user' ? (
138+
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
139+
{text}
140+
</div>
141+
) : (
142+
<div className='max-w-full text-[var(--text-primary)] text-base'>
143+
{text ? (
144+
<Streamdown
145+
className={cn(
146+
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
147+
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
148+
'[&_strong]:font-[600]',
149+
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
150+
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
151+
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
152+
)}
153+
>
154+
{text}
155+
</Streamdown>
156+
) : isStreaming ? (
157+
'…'
158+
) : sources.length === 0 ? (
159+
<span className='text-[var(--text-muted)]'>No answer returned.</span>
160+
) : null}
161+
</div>
162+
)}
163+
{sources.length > 0 && (
164+
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
165+
{sources.map((source) => (
166+
<a
167+
key={source.url}
168+
href={source.url}
169+
target='_blank'
170+
rel='noopener noreferrer'
171+
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
172+
>
173+
{source.title || source.url}
174+
</a>
175+
))}
176+
</div>
177+
)}
178+
</div>
179+
)
180+
})}
181+
182+
{error && (
183+
<p className='text-[var(--text-muted)] text-sm'>
184+
Something went wrong. Please try again.
185+
</p>
186+
)}
187+
</div>
188+
189+
<form onSubmit={handleSubmit} className='px-3 pb-3'>
190+
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
191+
<textarea
192+
ref={textareaRef}
193+
aria-label='Ask Sim about the docs'
194+
value={input}
195+
onChange={(event) => setInput(event.target.value)}
196+
onKeyDown={(event) => {
197+
if (event.key === 'Enter' && !event.shiftKey) {
198+
event.preventDefault()
199+
handleSubmit(event)
200+
}
201+
}}
202+
rows={1}
203+
placeholder='Ask Sim about the docs…'
204+
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
205+
/>
206+
{isBusy ? (
207+
<button
208+
type='button'
209+
aria-label='Stop'
210+
onClick={() => stop()}
211+
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
212+
>
213+
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
214+
</button>
215+
) : (
216+
<button
217+
type='submit'
218+
aria-label='Send'
219+
disabled={!input.trim()}
220+
className={cn(
221+
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
222+
input.trim()
223+
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
224+
: 'bg-[#808080]'
225+
)}
226+
>
227+
<ArrowUp className='size-[16px] text-white dark:text-black' />
228+
</button>
229+
)}
230+
</div>
231+
</form>
232+
</div>
233+
)
234+
}

0 commit comments

Comments
 (0)