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..f9488efc2d0 --- /dev/null +++ b/apps/docsite/src/__tests__/static-shell.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Guards the docsite's static shell against accidental de-optimisation. + * @input Reads the docsite's own source files under src/ + * @output Two invariants about which files may read request- or viewport-only + * state + * @position Cross-cutting meta-test; no runtime behaviour of its own + * + * The docsite runs with `cacheComponents: true` (Partial Prerendering). Two + * ordinary-looking React calls silently wreck a prerendered page, and neither + * fails a build, a typecheck, or a lint: + * + * 1. `useSearchParams()` hoists everything up to the nearest `` + * OUT of the static shell. On /components/[name] that used to be the + * whole article: the prerendered HTML held the header, sidebar and footer + * around an empty hole, so the page painted its chrome, sat blank, then + * dropped ~3900px of content in — a 0.23 CLS on every component page. + * + * 2. `useMediaQuery()` (and `useAppShellMobile()`, which wraps it) always + * answers `false` during a prerender. A layout that BRANCHES on it is + * therefore prerendered in its desktop form at every width, and a phone + * paints the desktop layout until hydration corrects it. The footer used + * to do this and its three regions landed on top of each other. + * + * Both are fine in a component that is genuinely interactive-only and behind a + * Suspense boundary with a real fallback — hence allowlists rather than bans. + */ + +import {describe, it, expect} from 'vitest'; +import {readdirSync, readFileSync, statSync} from 'node:fs'; +import {join, relative} from 'node:path'; + +const SRC_DIR = join(__dirname, '..'); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'generated') { + continue; + } + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) { + out.push(full); + } + } + return out; +} + +/** Files that import `symbol` from any module, as src-relative paths. */ +function importersOf(symbol: string): string[] { + const hits: string[] = []; + for (const file of sourceFiles(SRC_DIR)) { + const src = readFileSync(file, 'utf8'); + // Match the symbol only where it appears in an import clause, so a passing + // mention in a comment (ThemePackagePage explains why it avoids the hook) + // doesn't register as a use. + for (const m of src.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from/g)) { + if ( + m[1] + .split(',') + .some(s => s.trim().replace(/\s+as\s+.*$/, '') === symbol) + ) { + hits.push(relative(SRC_DIR, file)); + break; + } + } + } + return hits.sort(); +} + +describe('docsite static shell', () => { + it('only inherently-interactive routes read useSearchParams', () => { + // Every file here must sit under a on a route whose content is + // an interactive app rather than a document. Adding a file to this list + // means accepting that its page paints a hole first — measure the CLS + // before you do. Prefer local state seeded from the URL in an effect + // (see ComponentDetailClient) so the page can prerender whole. + expect(importersOf('useSearchParams')).toEqual([ + 'app/(site)/templates/page.tsx', + 'app/playground/PlaygroundClient.tsx', + ]); + }); + + it('the site footer does not branch on a media query', () => { + // 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. + expect(importersOf('useAppShellMobile')).not.toContain( + 'components/SiteFooter.tsx', + ); + expect(importersOf('useMediaQuery')).not.toContain( + 'components/SiteFooter.tsx', + ); + }); +}); diff --git a/apps/docsite/src/app/(site)/themes/page.tsx b/apps/docsite/src/app/(site)/themes/page.tsx index 7258644a006..e9e9f693468 100644 --- a/apps/docsite/src/app/(site)/themes/page.tsx +++ b/apps/docsite/src/app/(site)/themes/page.tsx @@ -1,102 +1,65 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** * Themes page — /themes * * Single canonical surface for browsing every Astryx theme. Renders the * full live ThemePackagePage (sidebar picker + themed preview * mockup + card showcase), seeded with the Neutral theme as the * default selection. * * The legacy per-theme route at /themes/ still resolves — * it now redirects here with ?theme=, which this page reads * to preselect the right theme in the sidebar so deep links from * docs, search, and shared URLs land on the requested theme rather * than the default seed. */ import type {Metadata} from 'next'; -import {Suspense} from 'react'; import {notFound} from 'next/navigation'; import {Section} from '@astryxdesign/core/Section'; import {packages} from '../../../generated/packageRegistry'; import {themeObjects} from '../../../generated/themeRegistry'; import {ThemePackagePage} from '../../../components/ThemePackagePage'; import {pageMetadata} from '../../../lib/pageMetadata'; // Static canonical metadata for /themes. The page also accepts a `?theme=` // param to preselect the picker, but every variant is the same surface, so the // canonical stays the bare /themes path to avoid duplicate-URL dilution. export const metadata: Metadata = pageMetadata({ title: 'Themes', description: 'Browse and preview every Astryx theme and see how design tokens, type, and components restyle across the gallery.', path: '/themes', }); // Default seed for the page — the picker opens with this theme // selected on first visit. Neutral is the most restrained / brand- // neutral theme in the gallery, so it sets a calm baseline before // users browse into the more expressive themes (Y2K, Butter, etc.). const DEFAULT_THEME_PACKAGE = '@astryxdesign/theme-neutral'; -function slugToPackageName(slug: string): string { - return `@astryxdesign/theme-${slug}`; -} - -export default function ThemesPage({ - searchParams, -}: { - searchParams: Promise<{theme?: string | string[]}>; -}) { - return ( -
- - - -
- ); -} - -async function SeededThemeExplorer({ - searchParams, -}: { - searchParams: Promise<{theme?: string | string[]}>; -}) { - // ?theme= preselects the picker. Falls back to the Neutral - // seed if the param is missing, malformed, or names a theme that - // isn't in the registry (so a stale link doesn't 404 on us — the - // user still lands on the explorer with a sensible default). - const params = await searchParams; - const rawSlug = params.theme; - const slug = Array.isArray(rawSlug) ? rawSlug[0] : rawSlug; - - const requestedPkgName = slug ? slugToPackageName(slug) : null; - const requestedPkg = requestedPkgName - ? packages.find(p => p.name === requestedPkgName) - : undefined; - const requestedTheme = requestedPkgName - ? themeObjects[requestedPkgName] - : undefined; - - // Use the requested theme if it resolved to a real package + theme - // object; otherwise fall back to the default seed so stale links - // still land on a usable page rather than a 404. - const seedPkg = - requestedPkg && requestedTheme - ? requestedPkg - : packages.find(p => p.name === DEFAULT_THEME_PACKAGE); - const seedTheme = - requestedPkg && requestedTheme - ? requestedTheme - : themeObjects[DEFAULT_THEME_PACKAGE]; +export default function ThemesPage() { + // Seeded with Neutral unconditionally so the page prerenders whole. The + // `?theme=` deep link used to be resolved here, from `await + // searchParams` inside a `` — which under + // `cacheComponents` (PPR) meant the explorer was NOT part of the static + // shell: the prerendered HTML was an empty Section and the whole page + // dropped in after hydration, a 0.22 CLS. ThemePackagePage already owns the + // selection as local state, so it now adopts `?theme=` itself on mount. + const seedPkg = packages.find(p => p.name === DEFAULT_THEME_PACKAGE); + const seedTheme = themeObjects[DEFAULT_THEME_PACKAGE]; if (!seedPkg || !seedTheme) { // Defensive: only fires if the @astryxdesign/theme-neutral package is // ever removed from the workspace, which would break the entire // themes section anyway. notFound(); } - return ; + return ( +
+ +
+ ); } 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 @@ -1,251 +1,270 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; import * as stylex from '@stylexjs/stylex'; import {Text} from '@astryxdesign/core/Text'; import {Link} from '@astryxdesign/core/Link'; import {Button} from '@astryxdesign/core/Button'; 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, DISCORD_URL, FACEBOOK_URL, INSTAGRAM_URL, THREADS_URL, X_URL, } from '../constants'; import { AstryxLogo, GitHubLogo, ThreadsLogo, XLogo, InstagramLogo, FacebookLogo, MetaOpenSourceLogo, 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. paddingTop: 'var(--astryx-marketing-section-gap, calc(var(--spacing-12) * 2))', }, astryxLogo: { height: 18, width: 'auto', display: 'block', color: 'var(--color-icon-secondary)', }, socialIcon: { width: 16, height: 16, display: 'block', }, metaOpenSourceLogo: { height: 14, width: 'auto', 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'}, }, }); const FOOTER_LINKS: ReadonlyArray<{ label: string; href: string; }> = [ {label: 'Docs', href: '/docs/getting-started'}, {label: 'Components', href: '/components'}, {label: 'Templates', href: '/templates'}, {label: 'Themes', href: '/themes'}, {label: 'Playground', href: '/playground'}, {label: 'Blog', href: '/blog'}, {label: 'Community', href: '/community'}, {label: 'Changelog', href: '/changelog'}, ]; const SOCIAL_LINKS: ReadonlyArray<{ label: string; href: string; Icon: (props: React.SVGProps) => React.ReactElement; }> = [ {label: 'GitHub', href: GITHUB_REPO, Icon: GitHubLogo}, {label: 'Discord', href: DISCORD_URL, Icon: DiscordLogo}, {label: 'Facebook', href: FACEBOOK_URL, Icon: FacebookLogo}, {label: 'Instagram', href: INSTAGRAM_URL, Icon: InstagramLogo}, {label: 'Threads', href: THREADS_URL, Icon: ThreadsLogo}, {label: 'X', href: X_URL, Icon: XLogo}, ]; const LEGAL_LINKS: ReadonlyArray<{label: string; href: string}> = [ {label: 'Terms of use', href: 'https://opensource.fb.com/legal/terms'}, {label: 'Privacy policy', href: 'https://opensource.fb.com/legal/privacy'}, ]; function NavLinks() { return ( <> {FOOTER_LINKS.map(item => ( {item.label} ))} ); } function SocialButtons() { return ( <> {SOCIAL_LINKS.map(social => (