From 0589f2c85e65f35dcb59ddbc610f49835e204a06 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 15 Oct 2026 09:53:45 +0200 Subject: [PATCH 01/53] perf(platforms): Load platform list client-side instead of per page (#19928) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## DESCRIBE YOUR PR Every page embedded the full platform/guide list up to three times (header, mobile nav, platform selector), in both its HTML and RSC payload. The selector's copy was also computed per page to link to "the same page" on every other platform. This serves the list once as a static `/platforms.json` (34 KB, ~5 KB gzipped), which client components preload in the background when the browser is idle. When switching platforms, the selector resolves the target URL on demand with `HEAD` requests, keeping the existing fallback order (platforms: same page → parent → root; guides: same page → root). Measured locally (full `next build` vs `master`, 4 static workers, warm MDX cache): - Static generation: **71.9s → ~57s (~−20%)** - Build output: **6.9 GB → 4.0 GB (−42%)** - Sidebar nav HTML is identical on all pages The selector shows a loading row (or an error with retry) if it's opened before the list has loaded, and a spinner on the trigger while the target page resolves. **Note: this does change the UI a bit, switching guides is slower than before (e.g. when you are on page X in guide A and switch to guide B in the dropdown).** IMHO that is an acceptable tradeoff here and it is not _too_ slow. ## IS YOUR CHANGE URGENT? Help us prioritize incoming PRs by letting us know when the change needs to go live. Select exactly one option. For deadlines, replace `YYYY-MM-DD` with the due date. You can update this information later by editing the PR description. - [ ] Urgent deadline (GA date, etc.): YYYY-MM-DD - [ ] Other deadline: YYYY-MM-DD - [x] No deadline: Not urgent, can wait up to 1 week+ ## SLA - Teamwork makes the dream work, so please add a reviewer to your PRs. - Please give the docs team up to 1 week to review your PR unless you've supplied a deadline. Thanks in advance for your help! ## PRE-MERGE CHECKLIST _Make sure you've checked the following before merging your changes:_ - [ ] Checked Vercel preview for correctness, including links - [ ] PR was reviewed and approved by any necessary SMEs (subject matter experts) - [ ] PR was reviewed and approved by a member of the [Sentry docs team](https://github.com/orgs/getsentry/teams/docs) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/not-found.tsx | 2 +- app/platforms.json/route.ts | 10 ++ src/components/TopNavClient.tsx | 8 +- src/components/docPage/index.tsx | 8 +- src/components/header.tsx | 5 +- src/components/home.tsx | 12 +- src/components/platformSelector/index.tsx | 97 +++++++++++++-- .../platformSelector/samePageUrl.spec.ts | 87 ++++++++++++++ .../platformSelector/samePageUrl.ts | 69 +++++++++++ .../platformSelector/style.module.scss | 23 ++++ src/components/sidebar/MobileSidebarNav.tsx | 6 +- src/components/sidebar/index.tsx | 77 +++--------- src/hooks/usePlatforms.tsx | 111 ++++++++++++++++++ 13 files changed, 424 insertions(+), 91 deletions(-) create mode 100644 app/platforms.json/route.ts create mode 100644 src/components/platformSelector/samePageUrl.spec.ts create mode 100644 src/components/platformSelector/samePageUrl.ts create mode 100644 src/hooks/usePlatforms.tsx diff --git a/app/not-found.tsx b/app/not-found.tsx index 861a1202790d75..af63b954429afd 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -43,7 +43,7 @@ export default function NotFound() { {isDeveloperDocs ? ( ) : ( -
+
)}

Page Not Found

diff --git a/app/platforms.json/route.ts b/app/platforms.json/route.ts new file mode 100644 index 00000000000000..e7e23718597af5 --- /dev/null +++ b/app/platforms.json/route.ts @@ -0,0 +1,10 @@ +import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; + +// Emitted as a static file at build time. Client components load this list +// lazily instead of every page embedding it. +export const dynamic = 'force-static'; + +export async function GET() { + const rootNode = await getDocsRootNode(); + return Response.json(extractPlatforms(rootNode)); +} diff --git a/src/components/TopNavClient.tsx b/src/components/TopNavClient.tsx index caca9d84858917..82a92f62e83809 100644 --- a/src/components/TopNavClient.tsx +++ b/src/components/TopNavClient.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import {usePathname, useRouter} from 'next/navigation'; import {useEffect, useRef, useState} from 'react'; import ReactDOM from 'react-dom'; +import {usePlatforms} from 'sentry-docs/hooks/usePlatforms'; import {Platform} from 'sentry-docs/types'; import {isLocalStorageAvailable} from 'sentry-docs/utils'; @@ -48,7 +49,10 @@ function useDropdownPosition(triggerRef, open) { return position; } -export default function TopNavClient({platforms}: {platforms: Platform[]}) { +const NO_PLATFORMS: Platform[] = []; + +export default function TopNavClient() { + const platforms = usePlatforms().platforms ?? NO_PLATFORMS; const [platformDropdownOpen, setPlatformDropdownOpen] = useState(false); const [platformDropdownByClick, setPlatformDropdownByClick] = useState(false); const platformBtnRef = useRef(null); @@ -601,7 +605,7 @@ export default function TopNavClient({platforms}: {platforms: Platform[]}) { align-items: center; } `} - + , document.body )} diff --git a/src/components/docPage/index.tsx b/src/components/docPage/index.tsx index 361c3ecf5a37e6..a81129dd68b7ab 100644 --- a/src/components/docPage/index.tsx +++ b/src/components/docPage/index.tsx @@ -2,7 +2,6 @@ import './type.scss'; import {ReactNode} from 'react'; import { - extractPlatforms, getCurrentGuide, getCurrentPlatform, getCurrentPlatformOrGuide, @@ -58,7 +57,6 @@ export async function DocPage({ const {rootNode, path} = serverContext(); const currentPlatform = getCurrentPlatform(rootNode, path); const currentGuide = getCurrentGuide(rootNode, path); - const platforms = extractPlatforms(rootNode); const platformOrGuide = getCurrentPlatformOrGuide(rootNode, path); const sdkPackage = await getSdkPackageName(platformOrGuide); @@ -79,11 +77,7 @@ export async function DocPage({ {isDeveloperDocs ? ( ) : ( -
+
)}
{sidebar ?? ( diff --git a/src/components/header.tsx b/src/components/header.tsx index 31df376c4f189a..cc063557e41381 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -8,7 +8,6 @@ import Link from 'next/link'; import {useCallback, useEffect, useState} from 'react'; import {useBodyScrollLock} from 'sentry-docs/hooks/useBodyScrollLock'; import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg'; -import {Platform} from 'sentry-docs/types'; import {MagicIcon} from './cutomIcons/magic'; import {mainSections} from './navigationData'; @@ -36,7 +35,6 @@ type Props = { pathname: string; searchPlatforms: string[]; noSearch?: boolean; - platforms?: Platform[]; useStoredSearchPlatforms?: boolean; }; @@ -45,7 +43,6 @@ export function Header({ searchPlatforms, noSearch, useStoredSearchPlatforms, - platforms = [], }: Props) { const isHomePage = pathname === '/'; const [homeMobileNavOpen, setHomeMobileNavOpen] = useState(false); @@ -259,7 +256,7 @@ export function Header({ {/* Top nav menu - anchored next to logo */}
- +
{/* Mobile: show search, Ask AI button and theme toggle (below md breakpoint) */}
diff --git a/src/components/home.tsx b/src/components/home.tsx index c032644a46ae91..dec3ad401c417c 100644 --- a/src/components/home.tsx +++ b/src/components/home.tsx @@ -2,7 +2,6 @@ import Image from 'next/image'; import Link from 'next/link'; import {Fragment, type ReactNode} from 'react'; import {Banner} from 'sentry-docs/components/banner'; -import {extractPlatforms, getDocsRootNode} from 'sentry-docs/docTree'; import Cursor from 'sentry-docs/icons/cursor'; import GitHub from 'sentry-docs/icons/github'; import Jira from 'sentry-docs/icons/jira'; @@ -19,17 +18,10 @@ import {PlatformIcon} from './platformIcon'; import {PreferredPlatformLink} from './preferredPlatformLink'; import {SentryWordmarkLogo} from './wordmarkLogo'; -export async function Home() { - const rootNode = await getDocsRootNode(); - const platforms = extractPlatforms(rootNode); +export function Home() { return (
-
+
diff --git a/src/components/platformSelector/index.tsx b/src/components/platformSelector/index.tsx index e8bb8ddb874eb4..7c9354b872b44f 100644 --- a/src/components/platformSelector/index.tsx +++ b/src/components/platformSelector/index.tsx @@ -1,6 +1,11 @@ 'use client'; import {Combobox, ComboboxItem, ComboboxList, ComboboxProvider} from '@ariakit/react'; -import {CaretRightIcon, CaretSortIcon, MagnifyingGlassIcon} from '@radix-ui/react-icons'; +import { + CaretRightIcon, + CaretSortIcon, + MagnifyingGlassIcon, + UpdateIcon, +} from '@radix-ui/react-icons'; import * as RadixSelect from '@radix-ui/react-select'; import {matchSorter} from 'match-sorter'; import {usePathname} from 'next/navigation'; @@ -15,25 +20,71 @@ import { useState, } from 'react'; import {PlatformIcon} from 'sentry-docs/components/platformIcon'; +import {usePlatforms} from 'sentry-docs/hooks/usePlatforms'; import {Platform, PlatformGuide, PlatformIntegration} from 'sentry-docs/types'; import {isLocalStorageAvailable, uniqByReference} from 'sentry-docs/utils'; import {SidebarLink, SidebarSeparator} from '../sidebar/sidebarLink'; +import {resolveSamePageUrl} from './samePageUrl'; import styles from './style.module.scss'; +const NO_PLATFORMS: Platform[] = []; + +/** + * Shown below the list while the full list of platforms is loading or failed to load. + */ +function PlatformsListStatus({ + status, + onRetry, +}: { + onRetry: () => void; + status: 'loading' | 'error'; +}) { + if (status === 'loading') { + return ( +
+ + Loading platforms… +
+ ); + } + return ( +
+ Couldn't load platforms. + +
+ ); +} + export function PlatformSelector({ - platforms, currentPlatform, + initialPlatforms = NO_PLATFORMS, alwaysOpen = false, listOnly = false, dropdownStyle = false, }: { - platforms: Array; alwaysOpen?: boolean; currentPlatform?: Platform | PlatformGuide; dropdownStyle?: boolean; + /** + * Rendered until the full list has loaded. Must contain the current platform or + * guide, as Radix Select needs the selected item to display it in the trigger. + */ + initialPlatforms?: Platform[]; listOnly?: boolean; }) { + const { + platforms: loadedPlatforms, + status: platformsStatus, + retry: retryPlatforms, + } = usePlatforms(); + const platforms = useMemo( + () => loadedPlatforms ?? initialPlatforms, + [loadedPlatforms, initialPlatforms] + ); + // humanize the title for a more natural sorting const humanizeTitle = (title: string) => title.replace(/\./g, ' ').replace(/ +/g, ' ').trim(); @@ -117,11 +168,28 @@ export function PlatformSelector({ if (isLocalStorageAvailable()) { localStorage.setItem('active-platform', platform_.key); } - // Use hard navigation for faster page load - window.location.href = platform_.url; + // Go to the same page for the selected platform if it exists. Finding it + // can take a moment, so show that we're navigating in the meantime. + setIsNavigating(true); + resolveSamePageUrl(platform_, pathname ?? '').then(url => { + // Use hard navigation for faster page load + window.location.href = url; + }); } }; + const [isNavigating, setIsNavigating] = useState(false); + useEffect(() => { + // Reset when coming back to this page from the back/forward cache + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) { + setIsNavigating(false); + } + }; + window.addEventListener('pageshow', onPageShow); + return () => window.removeEventListener('pageshow', onPageShow); + }, []); + const activeElementRef = useRef(null); useEffect(() => { if (!open) { @@ -233,6 +301,9 @@ export function PlatformSelector({ listOnly /> ))} + {platformsStatus !== 'loaded' && ( + + )}
); @@ -245,6 +316,7 @@ export function PlatformSelector({ onValueChange={onPlatformChange} open={open} onOpenChange={setOpen} + disabled={isNavigating} > startTransition(() => setSearchValue(v))} > {!alwaysOpen && ( - + - + {isNavigating ? ( + + ) : ( + + )} )} @@ -327,6 +407,9 @@ export function PlatformSelector({ ); })} + {platformsStatus !== 'loaded' && ( + + )} diff --git a/src/components/platformSelector/samePageUrl.spec.ts b/src/components/platformSelector/samePageUrl.spec.ts new file mode 100644 index 00000000000000..5e67fbc64ea94d --- /dev/null +++ b/src/components/platformSelector/samePageUrl.spec.ts @@ -0,0 +1,87 @@ +import {describe, expect, it} from 'vitest'; + +import {getSamePageCandidates} from './samePageUrl'; + +const platform = { + key: 'python', + name: 'python', + type: 'platform' as const, + url: '/platforms/python/', + guides: [], + integrations: [], +}; + +const guide = { + key: 'ruby.rails', + name: 'rails', + type: 'guide' as const, + url: '/platforms/ruby/guides/rails/', + platform: 'ruby', +}; + +const integration = { + key: 'python.django', + name: 'django', + type: 'integration' as const, + url: '/platforms/python/integrations/django/', + platform: 'python', + icon: 'python-django', +}; + +describe('getSamePageCandidates', () => { + it('tries the same page and then its parent for platforms', () => { + expect( + getSamePageCandidates( + platform, + '/platforms/javascript/guides/express/configuration/options/' + ) + ).toEqual([ + '/platforms/python/configuration/options/', + '/platforms/python/configuration/', + ]); + }); + + it('only tries the same page for guides', () => { + expect( + getSamePageCandidates(guide, '/platforms/javascript/configuration/options/') + ).toEqual(['/platforms/ruby/guides/rails/configuration/options/']); + }); + + it('does not try the parent page when it is the platform root', () => { + expect(getSamePageCandidates(platform, '/platforms/javascript/usage/')).toEqual([ + '/platforms/python/usage/', + ]); + }); + + it('uses the unversioned page for versioned pages', () => { + expect( + getSamePageCandidates( + platform, + '/platforms/javascript/guides/sveltekit/configuration/options__v8.x/' + ) + ).toEqual([ + '/platforms/python/configuration/options/', + '/platforms/python/configuration/', + ]); + expect( + getSamePageCandidates(guide, '/platforms/javascript__v7.x/configuration/options/') + ).toEqual([]); + }); + + it('returns nothing on platform and guide root pages', () => { + expect(getSamePageCandidates(platform, '/platforms/javascript/')).toEqual([]); + expect( + getSamePageCandidates(platform, '/platforms/javascript/guides/express/') + ).toEqual([]); + }); + + it('returns nothing outside of platform pages', () => { + expect(getSamePageCandidates(platform, '/product/issues/')).toEqual([]); + }); + + it('returns nothing for integrations', () => { + expect( + getSamePageCandidates(integration, '/platforms/javascript/configuration/options/') + ).toEqual([]); + }); +}); diff --git a/src/components/platformSelector/samePageUrl.ts b/src/components/platformSelector/samePageUrl.ts new file mode 100644 index 00000000000000..d4c65a1d5a51bf --- /dev/null +++ b/src/components/platformSelector/samePageUrl.ts @@ -0,0 +1,69 @@ +import {Platform, PlatformGuide, PlatformIntegration} from 'sentry-docs/types'; +import {getUnversionedPath} from 'sentry-docs/versioning'; + +type PlatformOrGuide = Pick< + Platform | PlatformGuide | PlatformIntegration, + 'type' | 'url' +>; + +/** + * Returns the URLs to try, in order, when switching to `target` from `pathname`, + * so that users land on the same page for the new platform when it exists: + * + * - platforms: the same page, then its parent page + * - guides: the same page + * - integrations: none + * + * The target's own URL (its root page) is the fallback and is not included. + */ +export function getSamePageCandidates( + target: PlatformOrGuide, + pathname: string +): string[] { + if (target.type === 'integration') { + return []; + } + + // Versioned pages (e.g. `/options__v8.x/`) map to the unversioned page + const parts = getUnversionedPath(pathname, false).split('/').filter(Boolean); + if (parts[0] !== 'platforms') { + return []; + } + // Drop /platforms/:platform or /platforms/:platform/guides/:guide + const isGuidePage = parts.length >= 4 && parts[2] === 'guides'; + const relativeParts = parts.slice(isGuidePage ? 4 : 2); + if (relativeParts.length === 0) { + return []; + } + + const candidates = [target.url + relativeParts.join('/') + '/']; + if (target.type === 'platform' && relativeParts.length > 1) { + candidates.push(target.url + relativeParts.slice(0, -1).join('/') + '/'); + } + return candidates; +} + +async function pageExists(url: string): Promise { + try { + const res = await fetch(url, {method: 'HEAD'}); + return res.ok; + } catch { + return false; + } +} + +/** + * Resolves the URL to navigate to when switching to `target`: the first existing + * page from {@link getSamePageCandidates}, or the target's root page. + */ +export async function resolveSamePageUrl( + target: PlatformOrGuide, + pathname: string +): Promise { + for (const candidate of getSamePageCandidates(target, pathname)) { + if (await pageExists(candidate)) { + return candidate; + } + } + return target.url; +} diff --git a/src/components/platformSelector/style.module.scss b/src/components/platformSelector/style.module.scss index 3c95508b264668..a985a568009b17 100644 --- a/src/components/platformSelector/style.module.scss +++ b/src/components/platformSelector/style.module.scss @@ -239,3 +239,26 @@ text-transform: uppercase; font-size: 0.8rem; } + +.list-status { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + color: var(--foreground-secondary); +} + +.retry-button { + margin-left: auto; + color: var(--accent-purple); + font-weight: 500; + + &:hover { + text-decoration: underline; + } +} + +.select[data-disabled] { + cursor: progress; +} diff --git a/src/components/sidebar/MobileSidebarNav.tsx b/src/components/sidebar/MobileSidebarNav.tsx index 16f56ee517abf8..cda9c84cd68284 100644 --- a/src/components/sidebar/MobileSidebarNav.tsx +++ b/src/components/sidebar/MobileSidebarNav.tsx @@ -4,12 +4,16 @@ import {ChevronDownIcon} from '@radix-ui/react-icons'; import Link from 'next/link'; import {usePathname} from 'next/navigation'; import {useEffect, useState} from 'react'; +import {usePlatforms} from 'sentry-docs/hooks/usePlatforms'; import {Platform} from 'sentry-docs/types'; import {isLocalStorageAvailable} from 'sentry-docs/utils'; import {mainSections} from '../navigationData'; -export function MobileSidebarNav({platforms = []}: {platforms?: Platform[]}) { +const NO_PLATFORMS: Platform[] = []; + +export function MobileSidebarNav() { + const platforms = usePlatforms().platforms ?? NO_PLATFORMS; const pathname = usePathname(); const isActive = (href: string) => pathname?.startsWith(href); diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index f9d8f008e871cc..bb42cb53cfee3b 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -1,12 +1,5 @@ -import { - extractPlatforms, - getCurrentGuide, - getCurrentPlatform, - getDocsRootNode, - nodeForPath, -} from 'sentry-docs/docTree'; +import {getCurrentGuide, getCurrentPlatform, getDocsRootNode} from 'sentry-docs/docTree'; import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; -import {Platform} from 'sentry-docs/types'; import {CloseSidebarOnNavigation, ScrollActiveLink} from '../focus-active-link'; import {PlatformSelector} from '../platformSelector'; @@ -36,70 +29,36 @@ export async function Sidebar({path, versions}: SidebarProps) { ); } - // Extract basic platforms list for SDK link redirection (used by MobileSidebarNav) - const basicPlatforms: Platform[] = !rootNode - ? [] - : extractPlatforms(rootNode).map(platform => ({ - ...platform, - guides: platform.guides, - })); - const currentPlatform = getCurrentPlatform(rootNode, path); const currentGuide = getCurrentGuide(rootNode, path); // Only show the platform selector and sidebar for SDKs/platforms section if (path[0] === 'platforms') { - const platforms: Platform[] = !rootNode - ? [] - : extractPlatforms(rootNode).map(platform => { - // take the :path in /platforms/:platformName/:path - // or /platforms/:platformName/guides/:guideName/:path when we're in a guide - const currentPathParts = path.slice(currentGuide ? 4 : 2); - - const platformPageForCurrentPath = - nodeForPath(rootNode, ['platforms', platform.name, ...currentPathParts]) || - // try to go one page higher, example: go to /usage/ from /usage/something - nodeForPath(rootNode, [ - 'platforms', - platform.name, - ...currentPathParts.slice(0, -1), - ]); - - return { - ...platform, - url: - platformPageForCurrentPath && !platformPageForCurrentPath.missing - ? '/' + platformPageForCurrentPath.path + '/' - : platform.url, - guides: platform.guides.map(guide => { - const guidePageForCurrentPath = nodeForPath(rootNode, [ - 'platforms', - platform.name, - 'guides', - guide.name, - ...currentPathParts, - ]); - return guidePageForCurrentPath && !guidePageForCurrentPath.missing - ? { - ...guide, - url: '/' + guidePageForCurrentPath.path + '/', - } - : guide; - }), - }; - }); + // The full platform list is loaded on the client. Until then, the selector + // only gets the current platform with the current guide (or the platform's + // own guide entry, which is selected when `platformTitle` is set). + const currentKey = (currentGuide ?? currentPlatform)?.key; + const initialPlatforms = currentPlatform + ? [ + { + ...currentPlatform, + guides: currentPlatform.guides.filter(g => g.key === currentKey), + integrations: [], + }, + ] + : []; return (