Files
sim/apps/docs/components/docs-layout/sidebar-components.tsx
T
Waleed fc25cfb3c8 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.
2026-07-13 09:10:33 -07:00

242 lines
7.8 KiB
TypeScript

'use client'
import { type ReactNode, useState } from 'react'
import type { Folder, Item, Separator } from 'fumadocs-core/page-tree'
import { useSidebar } from 'fumadocs-ui/components/sidebar/base'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { i18n } from '@/lib/i18n'
import { cn } from '@/lib/utils'
function SidebarChevron({ open, className }: { open: boolean; className?: string }) {
return (
<svg
width='5'
height='8'
viewBox='0 0 6 10'
fill='none'
className={cn(
'flex-shrink-0 transition-transform duration-200',
open && 'rotate-90',
className
)}
>
<path
d='M1 1L5 5L1 9'
stroke='currentColor'
strokeWidth='1.33'
strokeLinecap='square'
strokeLinejoin='miter'
/>
</svg>
)
}
const LANG_PREFIXES = i18n.languages.map((l) => `/${l}`)
function stripLangPrefix(path: string): string {
for (const prefix of LANG_PREFIXES) {
if (path === prefix) return '/'
if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length)
}
return path
}
function isActive(url: string, pathname: string, nested = true): boolean {
const normalizedPathname = stripLangPrefix(pathname)
const normalizedUrl = stripLangPrefix(url)
return (
normalizedUrl === normalizedPathname ||
(nested && normalizedPathname.startsWith(`${normalizedUrl}/`))
)
}
const ITEM_BASE =
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-muted)] text-sm transition-colors hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]'
const ITEM_ACTIVE_MOBILE = 'bg-[var(--surface-active)] font-medium text-[var(--text-primary)]'
const ITEM_DESKTOP =
'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:font-normal lg:text-[13px] lg:leading-tight'
const ITEM_TEXT = 'lg:text-[var(--text-body)]'
const ITEM_HOVER = 'lg:hover:bg-[var(--surface-3)]'
const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]'
const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-medium'
const FOLDER_HOVER = 'lg:hover:bg-[var(--surface-3)]'
const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]'
export function SidebarItem({ item }: { item: Item }) {
const pathname = usePathname()
const { prefetch } = useSidebar()
const active = isActive(item.url, pathname, false)
return (
<Link
href={item.url}
prefetch={prefetch}
data-active={active}
className={cn(
ITEM_BASE,
active && ITEM_ACTIVE_MOBILE,
ITEM_DESKTOP,
ITEM_TEXT,
!active && ITEM_HOVER,
active && ITEM_ACTIVE
)}
>
{item.name}
</Link>
)
}
function isApiReferenceFolder(node: Folder): boolean {
if (node.index?.url.includes('/api-reference/')) return true
for (const child of node.children) {
if (child.type === 'page' && child.url.includes('/api-reference/')) return true
if (child.type === 'folder' && isApiReferenceFolder(child)) return true
}
return false
}
export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
const pathname = usePathname()
const { prefetch } = useSidebar()
const hasActiveChild = checkHasActiveChild(item, pathname)
const isApiRef = isApiReferenceFolder(item)
const isOnApiRefPage = stripLangPrefix(pathname).startsWith('/api-reference')
const hasChildren = item.children.length > 0
const defaultOpen = hasActiveChild || (isApiRef && isOnApiRefPage)
const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null)
const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen
const toggleOpen = () => setManualOpen({ pathname, open: !open })
const active = item.index ? isActive(item.index.url, pathname, false) : false
if (item.index && !hasChildren) {
return (
<Link
href={item.index.url}
prefetch={prefetch}
data-active={active}
className={cn(
ITEM_BASE,
active && ITEM_ACTIVE_MOBILE,
ITEM_DESKTOP,
ITEM_TEXT,
!active && ITEM_HOVER,
active && ITEM_ACTIVE
)}
>
{item.name}
</Link>
)
}
return (
<div className='flex flex-col lg:mb-[0.0625rem]'>
<div className='flex w-full items-center lg:gap-0.5'>
{item.index ? (
<>
<Link
href={item.index.url}
prefetch={prefetch}
data-active={active}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'text-[var(--text-muted)] hover:bg-[var(--surface-active)] hover:text-[var(--text-body)]',
active && ITEM_ACTIVE_MOBILE,
'lg:block lg:flex-1 lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-[13px] lg:leading-tight',
FOLDER_TEXT,
!active && FOLDER_HOVER,
active && FOLDER_ACTIVE
)}
>
{item.name}
</Link>
{hasChildren && (
<button
onClick={toggleOpen}
className={cn(
'rounded-md p-1 hover:bg-[var(--surface-active)]',
'lg:cursor-pointer lg:rounded-md lg:p-1 lg:transition-colors lg:hover:bg-[var(--surface-3)]'
)}
aria-label={open ? 'Collapse' : 'Expand'}
>
<SidebarChevron open={open} className='text-[var(--text-icon)]' />
</button>
)}
</>
) : (
<button
onClick={toggleOpen}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
'text-[var(--text-muted)] hover:bg-[var(--surface-active)]',
'lg:flex lg:w-full lg:cursor-pointer lg:items-center lg:justify-between lg:rounded-lg lg:px-2.5 lg:py-1.5 lg:text-left lg:text-[13px] lg:leading-tight',
FOLDER_TEXT,
FOLDER_HOVER
)}
>
<span>{item.name}</span>
<SidebarChevron open={open} className='ml-auto text-[var(--text-icon)]' />
</button>
)}
</div>
{hasChildren && (
<div
className={cn(
'grid transition-[grid-template-rows,opacity] duration-200 ease-in-out',
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
>
<div className='overflow-hidden'>
<div className='ml-4 flex flex-col gap-0.5 lg:hidden'>{children}</div>
<ul className='mt-0.5 ml-2 hidden space-y-[0.0625rem] border-[var(--surface-active)] border-l pl-2.5 lg:block'>
{children}
</ul>
</div>
</div>
)}
</div>
)
}
export function SidebarSeparator({ item }: { item: Separator }) {
return (
<div
data-separator
className={cn('mt-5 mb-1.5 px-2', 'lg:relative lg:mt-0 lg:mb-1.5 lg:px-[13px] lg:pt-0')}
>
<div className='separator-divider hidden'>
<div className='h-[20px]' />
<div className='h-px bg-[var(--surface-active)]' />
<div className='h-[20px]' />
</div>
<p
className={cn(
'font-medium text-[var(--text-muted)] text-xs',
'lg:font-semibold lg:text-[10px] lg:text-[var(--text-muted)] lg:uppercase lg:tracking-[0.06em]'
)}
>
{item.name}
</p>
</div>
)
}
function checkHasActiveChild(node: Folder, pathname: string): boolean {
if (node.index && isActive(node.index.url, pathname)) {
return true
}
for (const child of node.children) {
if (child.type === 'page' && isActive(child.url, pathname)) {
return true
}
if (child.type === 'folder' && checkHasActiveChild(child, pathname)) {
return true
}
}
return false
}