diff --git a/apps/docsite/src/__tests__/static-shell.test.ts b/apps/docsite/src/__tests__/static-shell.test.ts index f9488efc2d0..d6ae12c7fbd 100644 --- a/apps/docsite/src/__tests__/static-shell.test.ts +++ b/apps/docsite/src/__tests__/static-shell.test.ts @@ -1,99 +1,63 @@ // 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 + * @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). 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. + * 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 {readdirSync, readFileSync, statSync} from 'node:fs'; -import {join, relative} from 'node:path'; +import {readFileSync} from 'node:fs'; +import {join} 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(); +function source(path: string): string { + return readFileSync(join(SRC_DIR, path), 'utf8'); } 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('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'); + + expect(themes).toContain('const params = await searchParams'); + expect(themes).toContain('fallback={}'); + expect(themes).not.toMatch(/]*fallback=\{null\}[\s\S]*?>/); }); 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', + 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 e9e9f693468..5fa707c29a1 100644 --- a/apps/docsite/src/app/(site)/themes/page.tsx +++ b/apps/docsite/src/app/(site)/themes/page.tsx @@ -1,65 +1,175 @@ // 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 * as stylex from '@stylexjs/stylex'; +import {Suspense} from 'react'; import {notFound} from 'next/navigation'; import {Section} from '@astryxdesign/core/Section'; +import {Skeleton} from '@astryxdesign/core/Skeleton'; 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'; -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]; +const THEME_SIDEBAR_BREAKPOINT = '@media (max-width: 900px)'; + +const styles = stylex.create({ + 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, + display: {default: 'block', [THEME_SIDEBAR_BREAKPOINT]: 'none'}, + }, + loadingRight: { + flex: '1 1 0', + minWidth: 0, + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-6)', + }, + loadingMobileContext: { + display: {default: 'none', [THEME_SIDEBAR_BREAKPOINT]: 'flex'}, + flexDirection: 'column', + gap: 'var(--spacing-3)', + }, + loadingMobileActions: { + display: 'flex', + gap: 'var(--spacing-2)', + }, + loadingPreview: { + height: {default: 720, [THEME_SIDEBAR_BREAKPOINT]: 520}, + }, +}); + +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]; 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 ; +} + +/** + * The theme explorer depends on the request query, so it remains a PPR hole. + * This fallback mirrors its two-column geometry and keeps the footer below the + * viewport instead of collapsing the section to zero height while it streams. + */ +function ThemeExplorerFallback() { return ( -
- -
+
+ +
+
+ + +
+ + +
+
+
+ +
+
+
); } diff --git a/apps/docsite/src/components/ThemePackagePage.tsx b/apps/docsite/src/components/ThemePackagePage.tsx index 62b55794039..2a2e910e94c 100644 --- a/apps/docsite/src/components/ThemePackagePage.tsx +++ b/apps/docsite/src/components/ThemePackagePage.tsx @@ -46,105 +46,100 @@ const THEME_SHOWCASE_SOURCE = // Gallery order — themes are listed in the same canonical visual- // closeness order used elsewhere (most restrained → most expressive). // Lives here so the sidebar's theme list reads in the same order as // /themes (Neutral → Stone → Gothic → Matcha → Y2K → Butter). Any // theme not in this list falls to the end alphabetically. const THEME_ORDER: ReadonlyArray = [ '@astryxdesign/theme-neutral', '@astryxdesign/theme-stone', '@astryxdesign/theme-gothic', '@astryxdesign/theme-matcha', '@astryxdesign/theme-y2k', '@astryxdesign/theme-butter', ]; // The package whose selection corresponds to the canonical bare // `/themes` URL (no `?theme=` query string). Must agree with the // `DEFAULT_THEME_PACKAGE` constant in `app/(site)/themes/page.tsx`, // which uses the same value to seed the page when no query param // is present — if these drift, the picker will round-trip the URL // (selecting the "default" theme would write a query that the // server then strips on reload, etc.). const DEFAULT_THEME_PACKAGE = '@astryxdesign/theme-neutral'; // The CLI command that copies a theme into the consumer's project as // editable source (see `astryx theme add`). The destination defaults to // `src/themes//`. We invoke the scoped package (`@astryxdesign/cli`) // rather than the bare `astryx` bin so the copy-paste command works even when // the CLI isn't installed yet — bare `npx astryx` would resolve to an // unrelated package on the npm registry. function themeScaffoldCommand(slug: string): string { return `npx @astryxdesign/cli theme add ${slug}`; } // Strip "Theme: " prefix and " Theme" suffix from the registered // displayName so the switcher labels read as the brand wordmark // ("Neutral", "Butter") rather than the redundant decorations. // Mirrors the same helper used on the /themes overview page. function themeLabel(displayName: string): string { return displayName.replace(/^Theme:\s*/, '').replace(/\s*Theme$/, ''); } // Strip the `@astryxdesign/theme-` prefix so the slug matches the URL form // used by both the dynamic redirect route (`/themes/`) and // the explorer's `?theme=` query param. Mirrored from the // helper on the server-side page.tsx so the encode/decode stays in // sync at a single import boundary. function packageNameToSlug(packageName: string): string { return packageName.replace(/^@astryxdesign\/theme-/, ''); } -/** Inverse of `packageNameToSlug` — resolves a `?theme=` slug to a package. */ -function slugToPackageName(slug: string): string { - return `@astryxdesign/theme-${slug}`; -} - // Below this viewport width the sidebar collapses to a compact // Selector dropdown + inline action row above the preview. The // sidebar is hidden via @media at the same breakpoint so the two // surfaces don't double-render. Picked so the right pane keeps // enough horizontal room for the themed preview's product grid. const SIDEBAR_QUERY = '(max-width: 900px)'; const SIDEBAR_BREAKPOINT = `@media ${SIDEBAR_QUERY}`; // Inert anchor for the showcase preview — preventDefaults clicks so demo // href="#" links don't scroll the docsite to the top. function PreviewAnchor({ onClick, ...props }: AnchorHTMLAttributes) { return ( { e.preventDefault(); onClick?.(e); }} /> ); } // Fixed sidebar width — compact enough that the right pane gets the // lion's share of horizontal space, wide enough to fit the longest // theme name, the hero heading + description, and the full-width // "Build a custom theme" / "How theming works" CTAs at size="md". const SIDEBAR_WIDTH = 260; // Sticky-top offset for the sidebar. Clears the docsite's sticky // AppShell top nav (which uses --appshell-header-height, // populated post-hydration) plus a touch of breathing room so the // sidebar pill doesn't look glued to the nav's bottom edge. const SIDEBAR_STICKY_TOP = 'calc(var(--appshell-header-height, 64px) + var(--spacing-4))'; const styles = stylex.create({ // Outer two-column container. Sidebar (fixed width) sits left, // right pane (flex:1) holds the existing preview + showcase. The // gap keeps the two surfaces from butting against each other. // alignItems:flex-start so the sticky sidebar's vertical reference // is the column top, not the (potentially shorter) sidebar height. twoColumn: { display: 'flex', flexDirection: 'row' as const, alignItems: 'flex-start', gap: 'var(--spacing-6)', [SIDEBAR_BREAKPOINT]: { @@ -660,124 +655,100 @@ function ThemeActions({selectedPkgName, customizeHref}: ThemeActionsProps) {