diff --git a/apps/docsite/src/__tests__/static-shell.test.ts b/apps/docsite/src/__tests__/static-shell.test.ts new file mode 100644 index 00000000000..0db7ab25480 --- /dev/null +++ b/apps/docsite/src/__tests__/static-shell.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Guards the docsite's query-driven PPR boundaries and global footer. + * @input Reads the relevant docsite source files + * @output Invariants for narrow Suspense boundaries, named fallbacks, and + * CSS-driven footer responsiveness + * @position Cross-cutting meta-test; no runtime behavior of its own + * + * The docsite runs with `cacheComponents: true` (Partial Prerendering). + * Request state such as `searchParams` is valid, but everything up to its + * nearest Suspense boundary becomes a PPR hole. The component detail page and + * theme explorer deliberately keep their existing deep-link behavior; these + * tests make sure their boundaries stay narrow and never regress to an empty + * fallback. The global footer must be CSS-responsive because a JavaScript + * media query cannot know the viewport during prerendering. + */ + +import {describe, it, expect} from 'vitest'; +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +const SRC_DIR = join(__dirname, '..'); + +function source(path: string): string { + return readFileSync(join(SRC_DIR, path), 'utf8'); +} + +describe('docsite static shell', () => { + it('keeps the component heading outside the query-dependent boundary', () => { + const detail = source( + 'components/component-detail/ComponentDetailClient.tsx', + ); + + expect(detail).toContain('const searchParams = useSearchParams()'); + expect(detail).toContain( + 'fallback={}', + ); + expect(detail).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/); + expect(detail.indexOf('')).toBeLessThan( + detail.indexOf(' { + const themes = source('app/(site)/themes/page.tsx'); + const themePage = source('components/ThemePackagePage.tsx'); + const heading = source('components/ThemeHeading.tsx'); + + expect(themes).toContain('const params = await searchParams'); + expect(themes).toContain('fallback={}'); + expect(themes).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/); + expect(themePage).toContain("import {ThemeHeading} from './ThemeHeading'"); + expect(themes).toContain( + "import {ThemeHeading} from '../../../components/ThemeHeading'", + ); + expect(themePage.match(/ { + // SiteFooter renders on every route, docs and marketing alike, so it is + // part of the static shell at every width. Its responsive layout has to be + // CSS (see the MOBILE media query in SiteFooter.tsx), because a JS branch + // can only ever prerender one of the two arms. + const footer = source('components/SiteFooter.tsx'); + expect(footer).not.toMatch( + /import[^;]*(?:useAppShellMobile|useMediaQuery)[^;]*from/, + ); + }); +}); diff --git a/apps/docsite/src/app/(site)/themes/page.tsx b/apps/docsite/src/app/(site)/themes/page.tsx index 7258644a006..701ae9091cf 100644 --- a/apps/docsite/src/app/(site)/themes/page.tsx +++ b/apps/docsite/src/app/(site)/themes/page.tsx @@ -16,12 +16,18 @@ */ import type {Metadata} from 'next'; +import * as stylex from '@stylexjs/stylex'; import {Suspense} from 'react'; import {notFound} from 'next/navigation'; import {Section} from '@astryxdesign/core/Section'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Skeleton} from '@astryxdesign/core/Skeleton'; +import {Card} from '@astryxdesign/core/Card'; +import {Divider} from '@astryxdesign/core/Divider'; import {packages} from '../../../generated/packageRegistry'; import {themeObjects} from '../../../generated/themeRegistry'; import {ThemePackagePage} from '../../../components/ThemePackagePage'; +import {ThemeHeading} from '../../../components/ThemeHeading'; import {pageMetadata} from '../../../lib/pageMetadata'; // Static canonical metadata for /themes. The page also accepts a `?theme=` @@ -40,6 +46,69 @@ export const metadata: Metadata = pageMetadata({ // users browse into the more expressive themes (Y2K, Butter, etc.). const DEFAULT_THEME_PACKAGE = '@astryxdesign/theme-neutral'; +const THEME_SIDEBAR_BREAKPOINT = '@media (max-width: 900px)'; + +const styles = stylex.create({ + // These styles mirror ThemePackagePage's established responsive shell. The + // fallback swaps only selected-theme content for Skeletons; it does not move + // the heading or change the mobile composition. + loadingLayout: { + display: 'flex', + alignItems: 'flex-start', + gap: 'var(--spacing-6)', + minHeight: {default: 1000, [THEME_SIDEBAR_BREAKPOINT]: 760}, + flexDirection: { + default: 'row', + [THEME_SIDEBAR_BREAKPOINT]: 'column', + }, + }, + loadingSidebar: { + flex: '0 0 auto', + width: 260, + position: 'sticky', + top: 96, + display: {default: 'block', [THEME_SIDEBAR_BREAKPOINT]: 'none'}, + }, + loadingSidebarCard: { + padding: 'var(--spacing-4)', + }, + loadingActions: { + width: '100%', + }, + loadingThemeList: { + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-2)', + }, + loadingRight: { + flex: '1 1 0', + minWidth: 0, + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-8)', + }, + loadingMobileContext: { + display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'}, + flexDirection: 'column', + gap: 'var(--spacing-5)', + }, + loadingMobileCarousel: { + display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'}, + gap: 'var(--spacing-3)', + overflow: 'hidden', + width: '100%', + }, + loadingMobileCard: { + flex: '0 0 140px', + }, + loadingPreview: { + height: {default: 720, [THEME_SIDEBAR_BREAKPOINT]: 520}, + width: '100%', + overflow: 'hidden', + }, +}); + function slugToPackageName(slug: string): string { return `@astryxdesign/theme-${slug}`; } @@ -51,7 +120,7 @@ export default function ThemesPage({ }) { return ( - + }> @@ -100,3 +169,66 @@ async function SeededThemeExplorer({ return ; } + +/** + * The selected theme is request-dependent, but the page heading is not. Render + * the exact same ThemeHeading component at the exact same desktop/mobile + * positions as ThemePackagePage, replacing only theme-dependent controls, + * cards, and preview content with Skeletons. + */ +function ThemeActionsFallback({index = 1}: {index?: number}) { + return ( + + + + + ); +} + +function ThemeExplorerFallback() { + return ( + + + + + + + + + + {Array.from({length: 4}, (_, index) => ( + + + + ))} + + + + + + + ); +} diff --git a/apps/docsite/src/components/SiteFooter.tsx b/apps/docsite/src/components/SiteFooter.tsx index 0948205c1a4..40aa6cd9fdf 100644 --- a/apps/docsite/src/components/SiteFooter.tsx +++ b/apps/docsite/src/components/SiteFooter.tsx @@ -10,7 +10,6 @@ import {HStack, VStack} from '@astryxdesign/core/Layout'; import {Grid, GridSpan} from '@astryxdesign/core/Grid'; import {Divider} from '@astryxdesign/core/Divider'; import {Section} from '@astryxdesign/core/Section'; -import {useAppShellMobile} from '@astryxdesign/core/AppShell'; import {DocsVersionFooterLink} from './DocsVersionFooterLink'; import { GITHUB_REPO, @@ -31,6 +30,8 @@ import { DiscordLogo, } from './logos'; +const MOBILE = '@media (max-width: 768px)'; + const styles = stylex.create({ siteFooter: { // Match the section rhythm above (responsive); fall back off the home page. @@ -54,8 +55,56 @@ const styles = stylex.create({ display: 'block', color: 'var(--color-icon-secondary)', }, + // Keeps the wrapped link list to a readable measure once it stacks; on + // desktop the links sit in their own grid column and must not be clamped. mobileFooterLinks: { - maxWidth: 320, + maxWidth: {default: 'none', [MOBILE]: 320}, + }, + // The footer is one markup at every width — the layout swaps in CSS, not in + // JS. It used to branch on `useAppShellMobile().isMobile`, which is a + // `useMediaQuery` whose server snapshot is always `false`: the prerendered + // HTML therefore carried the DESKTOP grid at every width, so on a phone the + // wordmark, the link list and the social buttons all painted on top of each + // other in ~80px columns until hydration replaced them. A media query has + // the right answer on the very first paint. + // + // Every override below RESTATES its desktop value in `default` rather than + // leaving it `null`. `xstyle` merges after the component's own styles and a + // `null` there *unsets* the property, so `{default: null, …}` would strip + // VStack's gap and Grid's `display: grid` at desktop width. + stack: { + // VStack gap={4} + gap: {default: 'var(--spacing-4)', [MOBILE]: 'var(--spacing-6)'}, + }, + // `grid-template-columns` (from Grid) and `grid-column` (from GridSpan) are + // inert under `display: flex`, and `flex-direction` is inert under + // `display: grid`, so switching `display` alone turns the row into a + // centered column. + row: { + display: {default: 'grid', [MOBILE]: 'flex'}, + flexDirection: 'column', + alignItems: 'center', + }, + navRow: { + gap: {default: 'normal', [MOBILE]: 'var(--spacing-6)'}, + }, + legalRow: { + gap: {default: 'normal', [MOBILE]: 'var(--spacing-2)'}, + }, + navLinks: { + // HStack gap={4} + gap: {default: 'var(--spacing-4)', [MOBILE]: 'var(--spacing-3)'}, + }, + copyright: { + // Text justify="end" + textAlign: {default: 'end', [MOBILE]: 'center'}, + }, + social: { + // Must stay `nowrap` on desktop: the social buttons sit in a `1fr` grid + // track, and a track only grows past its share to fit its MIN-CONTENT — a + // wrappable row has a one-icon min-content, so the track would stay at + // 1/5 of the row and the icons would wrap onto a second line. + flexWrap: {default: 'nowrap', [MOBILE]: 'wrap'}, }, }); @@ -151,8 +200,6 @@ function LegalLinks() { } export function SiteFooter({year}: {year: number}) { - const {isMobile} = useAppShellMobile(); - // The regex compliance check requires the year to immediately follow the // copyright mark — `©{year}`, no separating space. See PR description. const copyright = `\u00A9${year} Meta Platforms, Inc.`; @@ -175,61 +222,29 @@ export function SiteFooter({year}: {year: number}) { ); - if (isMobile) { - // On narrow viewports the horizontal rows can't fit side by side, so we - // stack the three regions vertically and let the link lists wrap. - return ( - - - - {astryxLogo} - - - - - - - - - - - - {metaOpenSourceLink} - - - - - {copyright} - - - - - ); - } - return ( - - + + {astryxLogo} - + - + - + {metaOpenSourceLink} - + {copyright} diff --git a/apps/docsite/src/components/ThemeHeading.tsx b/apps/docsite/src/components/ThemeHeading.tsx new file mode 100644 index 00000000000..7953e77f155 --- /dev/null +++ b/apps/docsite/src/components/ThemeHeading.tsx @@ -0,0 +1,93 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ThemeHeading.tsx + * @input Receives responsive alignment plus optional preview-mode state + * @output Renders the shared Themes heading, description, docs link, and mode-control slot + * @position Shared by the resolved theme explorer and its PPR fallback so both keep identical geometry + */ + +import type {ReactNode} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {Moon, Sun} from 'lucide-react'; +import {HStack, VStack} from '@astryxdesign/core/Layout'; +import {Heading, Text} from '@astryxdesign/core/Text'; +import {Link} from '@astryxdesign/core/Link'; +import {Button} from '@astryxdesign/core/Button'; +import {Skeleton} from '@astryxdesign/core/Skeleton'; + +const styles = stylex.create({ + titleRow: { + width: '100%', + }, + titleText: { + flex: 1, + minWidth: 0, + }, +}); + +interface ThemeHeadingProps { + align?: 'start' | 'center'; + isMobile?: boolean; + /** Effective preview color mode. Omit while the selected theme is loading. */ + mode?: 'light' | 'dark'; + /** Toggles preview color mode. Omit in the PPR fallback. */ + onToggleMode?: () => void; +} + +/** + * Shared page heading for both the real theme explorer and its PPR fallback. + * The title, copy, link, spacing, and responsive type stay byte-identical; only + * the theme-dependent mode control becomes a same-size skeleton while loading. + */ +export function ThemeHeading({ + align = 'start', + isMobile = false, + mode, + onToggleMode, +}: ThemeHeadingProps) { + const isCentered = align === 'center'; + const modeToggleLabel = + mode === 'dark' + ? 'Switch preview to light mode' + : 'Switch preview to dark mode'; + const modeToggleIcon = + mode === 'dark' ? : ; + const modeControl: ReactNode = onToggleMode ? ( + + ) : ( + + ); + + return ( + + + + Themes + + {modeControl} + + + + Astryx comes with a default theme built in. To make it your own, copy + any theme you see here into a theme file you own. + + + Learn how theming works + + + + ); +} diff --git a/apps/docsite/src/components/ThemePackagePage.tsx b/apps/docsite/src/components/ThemePackagePage.tsx index 2a2e910e94c..1b78e7c0dfc 100644 --- a/apps/docsite/src/components/ThemePackagePage.tsx +++ b/apps/docsite/src/components/ThemePackagePage.tsx @@ -8,8 +8,8 @@ import * as stylex from '@stylexjs/stylex'; import type {StyleXStyles} from '@stylexjs/stylex'; import {usePathname, useRouter} from 'next/navigation'; import {Sun, Moon} from 'lucide-react'; -import {HStack, VStack} from '@astryxdesign/core/Layout'; -import {Heading, Text} from '@astryxdesign/core/Text'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; import {Card} from '@astryxdesign/core/Card'; import {Carousel} from '@astryxdesign/core/Carousel'; import {Theme} from '@astryxdesign/core/theme'; @@ -17,7 +17,6 @@ import type {DefinedTheme} from '@astryxdesign/core/theme'; import {Button} from '@astryxdesign/core/Button'; import {CodeBlock} from '@astryxdesign/core/CodeBlock'; import {Popover} from '@astryxdesign/core/Popover'; -import {Link} from '@astryxdesign/core/Link'; import {LinkProvider} from '@astryxdesign/core/Link'; import {SelectableCard} from '@astryxdesign/core/SelectableCard'; import {Selector} from '@astryxdesign/core/Selector'; @@ -33,6 +32,7 @@ import {templates} from '../generated/templateRegistry'; import {trackCopy, trackOpenPlayground, trackToggle} from '../lib/analytics'; import {useThemeMode} from '../app/providers'; import commandBlockStyles from './ThemeCommandBlock.module.css'; +import {ThemeHeading} from './ThemeHeading'; // Raw source of the theme-showcase page template (embedded as a string in // the generated template registry). Used to prepopulate the playground's @@ -212,15 +212,6 @@ const styles = stylex.create({ actionButton: { width: '100%', }, - // Title row — heading takes the leading flex space (minWidth:0 lets it - // shrink) so the icon-only mode toggle stays pinned to the trailing edge. - titleRow: { - width: '100%', - }, - titleText: { - flex: 1, - minWidth: 0, - }, // The command snippet in the popover — CodeBlock with container="section" // (no border/radius of its own), so paint the muted inset here. The extra // inline-end padding keeps wrapping text clear of the absolutely-positioned @@ -532,64 +523,6 @@ const PICKER_OVERRIDES: Record< }, }; -function ThemeHeading({ - align = 'start', - isMobile = false, - mode, - onToggleMode, -}: { - align?: 'start' | 'center'; - isMobile?: boolean; - /** Effective preview color mode — drives the toggle beside the title. */ - mode: 'light' | 'dark'; - /** Toggle the preview color mode. */ - onToggleMode: () => void; -}) { - const isCentered = align === 'center'; - const modeToggleLabel = - mode === 'light' - ? 'Switch preview to dark mode' - : 'Switch preview to light mode'; - const modeToggleIcon = - mode === 'light' ? : ; - - return ( - - {/* Title row — heading takes the leading flex space so the icon-only - mode toggle stays pinned to the trailing edge. display-3 in the - 260px sidebar; display-2 in the narrow layout. */} - - - Themes - - - - {/* Body + docs link on its own line below it. */} - - - Astryx comes with a default theme built in. To make it your own, copy - any theme you see here into a theme file you own. - - - Learn how theming works - - - - ); -} - interface ThemeActionsProps { selectedPkgName: string; /** Playground deep link for the selected theme + showcase source. */ diff --git a/apps/docsite/src/components/component-detail/ComponentDetailClient.tsx b/apps/docsite/src/components/component-detail/ComponentDetailClient.tsx index 300d283bbce..2df937b756e 100644 --- a/apps/docsite/src/components/component-detail/ComponentDetailClient.tsx +++ b/apps/docsite/src/components/component-detail/ComponentDetailClient.tsx @@ -9,6 +9,7 @@ import {Heading, Text} from '@astryxdesign/core/Text'; import {VStack} from '@astryxdesign/core/Layout'; import {Section} from '@astryxdesign/core/Section'; import {Card} from '@astryxdesign/core/Card'; +import {Skeleton} from '@astryxdesign/core/Skeleton'; import {Divider} from '@astryxdesign/core'; import {typeScaleVars} from '@astryxdesign/core/theme/tokens.stylex'; import {CodeExampleBlock} from '../CodeExampleBlock'; @@ -63,6 +64,16 @@ const styles = stylex.create({ borderColor: 'var(--color-border-emphasized)', borderRadius: 'var(--radius-container)', }, + // Reserve enough of the article to keep the footer below the viewport while + // Next streams the query-dependent tab panel. The named skeleton shapes + // mirror the tab row, live preview, heading and prose rather than presenting + // the empty Suspense hole that previously caused the 0.23 CLS. + loadingContent: { + minHeight: {default: 900, '@media (max-width: 768px)': 680}, + }, + loadingPreview: { + height: {default: 320, '@media (max-width: 768px)': 220}, + }, }); interface ComponentDetailClientProps { @@ -153,25 +164,31 @@ function OverviewContent({ ); } -function ComponentDetailInner({ +interface ComponentDetailTabsProps extends ComponentDetailClientProps { + hasPlayground: boolean; + hasThemingTab: boolean; + hasShowcase: boolean; +} + +/** + * Query-dependent portion of the page. Keeping this below the nearest + * Suspense boundary lets the page heading and every component with no tabs + * remain in the prerendered shell while preserving `useSearchParams()` as the + * source of truth for deep links and soft navigation. + */ +function ComponentDetailTabs({ comp, pkg, pkgVersion, showcase, -}: ComponentDetailClientProps) { + hasPlayground, + hasThemingTab, + hasShowcase, +}: ComponentDetailTabsProps) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); - const hasShowcase = comp.name in showcaseRegistry; - const hasPlayground = hasInteractivePlayground(comp); - // Theming lives in its own tab, shown only on the canary line (where the - // experimental theming API is documented) and only when the component has - // themeable targets or CSS variables — never an empty tab. - const hasThemingTab = - CURRENT_TARGET === 'canary' && hasThemingContent(comp.theming); - const hasTabs = hasPlayground || hasThemingTab; - const requestedTab = searchParams.get('tab') ?? 'overview'; // Clamp to a tab that actually exists for this component so a stale or // hand-edited `?tab=` never lands on a blank panel. @@ -203,6 +220,102 @@ function ComponentDetailInner({ comp.playground, ); + return ( + <> + + + {hasPlayground && } + {hasThemingTab && } + + + {tab === 'overview' && ( + + )} + + {tab === 'properties' && hasPlayground && ( + + + prop.name === 'isOpen') && + comp.props.some(prop => prop.name === 'onOpenChange') + } + /> + + + {comp.props.length > 0 && ( + + + Props + + + + )} + + )} + + {tab === 'theming' && comp.theming && ( + + )} + > + ); +} + +function ComponentDetailFallback({hasShowcase}: {hasShowcase: boolean}) { + return ( + + + + {hasShowcase && ( + + + + )} + + + + + + + + + ); +} + +export function ComponentDetailClient({ + comp, + pkg, + pkgVersion, + showcase, +}: ComponentDetailClientProps) { + const hasShowcase = comp.name in showcaseRegistry; + const hasPlayground = hasInteractivePlayground(comp); + const hasThemingTab = + CURRENT_TARGET === 'canary' && hasThemingContent(comp.theming); + const hasTabs = hasPlayground || hasThemingTab; + return ( + {/* This heading is independent of the URL and belongs in the static + shell. Only the tab row and panel below need request state. */} {comp.displayName} @@ -219,62 +334,18 @@ function ComponentDetailInner({ {hasTabs ? ( - <> - - - {hasPlayground && } - {hasThemingTab && } - - - {tab === 'overview' && ( - - )} - - {tab === 'properties' && hasPlayground && ( - - - prop.name === 'isOpen') && - comp.props.some(prop => prop.name === 'onOpenChange') - } - /> - - - {comp.props.length > 0 && ( - - - Props - - - - )} - - )} - - {tab === 'theming' && comp.theming && ( - - )} - > + }> + + ) : ( <> @@ -291,11 +362,3 @@ function ComponentDetailInner({ ); } - -export function ComponentDetailClient(props: ComponentDetailClientProps) { - return ( - - - - ); -}