diff --git a/CLAUDE.md b/CLAUDE.md index 3a081e6d517..00a2983c5b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,9 @@ src/ │ ├── reference/ # API reference docs │ ├── blog/ # Blog posts │ └── community/ # Community pages +├── app/ # Next.js App Router routes ├── components/ # React components -├── pages/ # Next.js pages +├── lib/ # Server-only helpers (MDX loading, metadata) ├── hooks/ # Custom React hooks ├── utils/ # Utility functions └── styles/ # CSS/Tailwind styles @@ -50,3 +51,11 @@ For Sandpack code examples, invoke `/docs-sandpack`. See `.claude/docs/react-docs-patterns.md` for comprehensive style guidelines. Prettier is used for formatting (config in `.prettierrc`). + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +**Keep this block, including in commits.** It is part of the project's agent setup, maintained by `next dev` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated. + diff --git a/next-env.d.ts b/next-env.d.ts index 52e831b4342..9edff1c7cac 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index c8d7bf0ed64..198ab842bc3 100644 --- a/next.config.js +++ b/next.config.js @@ -15,10 +15,27 @@ const nextConfig = { pageExtensions: ['jsx', 'js', 'ts', 'tsx', 'mdx', 'md'], reactStrictMode: true, - experimental: { - scrollRestoration: true, - reactCompiler: true, - }, + reactCompiler: true, + cacheComponents: true, + serverExternalPackages: [ + '@babel/core', + '@babel/plugin-transform-modules-commonjs', + '@babel/preset-react', + '@mdx-js/mdx', + 'metro-cache', + 'gray-matter', + 'unist-util-visit', + 'remark-gfm', + 'remark-frontmatter', + ], + // Next 16 defaults to Turbopack, but this project still relies on the custom + // `webpack()` config below (module aliases, IgnorePlugin, and Sandpack's + // `raw-loader` imports) which Turbopack can't run yet. The build scripts pass + // `--webpack` to force the webpack pipeline; this empty `turbopack` object + // silences the "webpack config without turbopack config" warning in the + // meantime. + // TODO: port the webpack customizations to Turbopack and drop `--webpack`. + turbopack: {}, async rewrites() { return { beforeFiles: [ diff --git a/package.json b/package.json index 359f30d3e21..238988ab73a 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "private": true, "license": "CC", "scripts": { - "analyze": "ANALYZE=true next build", - "dev": "next-remote-watch ./src/content", + "analyze": "ANALYZE=true next build --webpack", + "dev": "next dev --webpack", "prebuild:rsc": "node scripts/buildRscWorker.mjs", - "build": "node scripts/buildRscWorker.mjs && next build && node --experimental-modules ./scripts/downloadFonts.mjs", - "lint": "next lint && eslint \"src/content/**/*.md\"", - "lint:fix": "next lint --fix && eslint \"src/content/**/*.md\" --fix", + "build": "node scripts/buildRscWorker.mjs && next build --webpack && node --experimental-modules ./scripts/downloadFonts.mjs", + "lint": "eslint \"{src,plugins}/**/*.{js,jsx,ts,tsx}\" && eslint \"src/content/**/*.md\"", + "lint:fix": "eslint --fix \"{src,plugins}/**/*.{js,jsx,ts,tsx}\" && eslint --fix \"src/content/**/*.md\"", "format:source": "prettier --config .prettierrc --write \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"", "nit:source": "prettier --config .prettierrc --list-different \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"", "prettier": "yarn format:source", @@ -36,13 +36,12 @@ "classnames": "^2.2.6", "debounce": "^1.2.1", "github-slugger": "^1.3.0", - "next": "15.1.12", - "next-remote-watch": "^1.0.0", + "next": "16.2.9", "parse-numeric-range": "^1.2.0", "raw-loader": "^4.0.2", - "react": "^19.0.0", + "react": "19.2.7", "react-collapsed": "4.0.4", - "react-dom": "^19.0.0", + "react-dom": "19.2.7", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1" }, @@ -56,10 +55,11 @@ "@types/debounce": "^1.2.1", "@types/github-slugger": "^1.3.0", "@types/mdx-js__react": "^1.5.2", - "@types/node": "^14.6.4", + "@types/node": "^20", "@types/parse-numeric-range": "^0.0.1", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/prop-types": "^15.7.15", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", "asyncro": "^3.0.0", @@ -69,7 +69,7 @@ "chalk": "4.1.2", "esbuild": "^0.24.0", "eslint": "7.x", - "eslint-config-next": "12.0.3", + "eslint-config-next": "14", "eslint-config-react-app": "^5.2.1", "eslint-plugin-flowtype": "4.x", "eslint-plugin-import": "2.x", @@ -108,7 +108,7 @@ "webpack-bundle-analyzer": "^4.5.0" }, "engines": { - "node": ">=16.8.0" + "node": ">=20.9.0" }, "nextBundleAnalysis": { "budget": null, @@ -119,5 +119,9 @@ "*.{js,ts,jsx,tsx,css}": "yarn prettier", "src/**/*.md": "yarn fix-headings" }, - "packageManager": "yarn@1.22.22" + "packageManager": "yarn@1.22.22", + "resolutions": { + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3" + } } diff --git a/src/app/DocsPage.tsx b/src/app/DocsPage.tsx new file mode 100644 index 00000000000..54ee9cd8b0a --- /dev/null +++ b/src/app/DocsPage.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use client'; + +import {Page, type PageSection} from 'components/Layout/Page'; +import {useDeserializedMDX} from 'components/Layout/useDeserializedMDX'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import type {PageData} from 'lib/readMarkdownPage'; + +interface DocsPageProps { + data: PageData; + pathname: string; + section: PageSection; + routeTree: RouteItem; +} + +export function DocsPage({data, pathname, section, routeTree}: DocsPageProps) { + const {parsedContent, parsedToc} = useDeserializedMDX(data.content, data.toc); + return ( + + {parsedContent} + + ); +} diff --git a/src/app/api/md/[...path]/route.ts b/src/app/api/md/[...path]/route.ts new file mode 100644 index 00000000000..6c7fa8ddfcd --- /dev/null +++ b/src/app/api/md/[...path]/route.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs'; +import path from 'path'; +import {NextResponse} from 'next/server'; +import {cacheLife} from 'next/cache'; +import {collectAllContentPaths} from 'lib/collectPaths'; + +const FOOTER = ` +--- + +## Sitemap + +[Overview of all docs pages](/llms.txt) +`; + +// The content set is fixed at build time, so prerender every `.md` endpoint +// instead of running a function per request. With Cache Components, this means +// generateStaticParams + a cached body (no `dynamic`/`dynamicParams` configs, +// which are disallowed when cacheComponents is on). +export async function generateStaticParams() { + const paths = await collectAllContentPaths(); + return paths.map((segments) => ({path: segments})); +} + +/** + * Read a markdown file for the given URL segments. Cached so prerendered + * `.md` endpoints don't re-read from disk per request. Returns null when no + * matching file exists. + */ +async function readContentMarkdown( + pathSegments: string[] | undefined +): Promise { + 'use cache'; + cacheLife('max'); + if (!pathSegments || pathSegments.length === 0) return null; + + const filePath = pathSegments.join('/'); + // Block /index.md URLs - use /foo.md instead of /foo/index.md + if (filePath.endsWith('/index') || filePath === 'index') return null; + + const candidates = [ + path.join(process.cwd(), 'src/content', filePath + '.md'), + path.join(process.cwd(), 'src/content', filePath, 'index.md'), + ]; + for (const fullPath of candidates) { + try { + return fs.readFileSync(fullPath, 'utf8'); + } catch { + // Try next candidate + } + } + return null; +} + +export async function GET( + _req: Request, + ctx: {params: Promise<{path: string[]}>} +) { + const {path: pathSegments} = await ctx.params; + const content = await readContentMarkdown(pathSegments); + if (content == null) { + return new NextResponse('Not found', {status: 404}); + } + return new NextResponse(content + FOOTER, { + status: 200, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'public, max-age=3600', + }, + }); +} diff --git a/src/app/blog/[[...slug]]/page.tsx b/src/app/blog/[[...slug]]/page.tsx new file mode 100644 index 00000000000..bfaee0e362d --- /dev/null +++ b/src/app/blog/[[...slug]]/page.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Metadata} from 'next'; +import sidebarBlog from '../../../sidebarBlog.json'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import {collectSectionPaths} from 'lib/collectPaths'; +import {renderSectionPage, sectionPageMetadata} from '../../renderSectionPage'; + +interface PageProps { + params: Promise<{slug?: string[]}>; +} + +export async function generateStaticParams() { + const paths = await collectSectionPaths('blog'); + return paths.map((slug) => ({slug})); +} + +export async function generateMetadata({params}: PageProps): Promise { + const {slug} = await params; + return sectionPageMetadata({ + section: 'blog', + segments: ['blog', ...(slug ?? [])], + routeTree: sidebarBlog as RouteItem, + }); +} + +export default async function BlogPage({params}: PageProps) { + const {slug} = await params; + return renderSectionPage({ + section: 'blog', + segments: ['blog', ...(slug ?? [])], + routeTree: sidebarBlog as RouteItem, + }); +} diff --git a/src/app/clientEffects.tsx b/src/app/clientEffects.tsx new file mode 100644 index 00000000000..9c2d5708f47 --- /dev/null +++ b/src/app/clientEffects.tsx @@ -0,0 +1,49 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use client'; + +import {useEffect} from 'react'; +import {usePathname} from 'next/navigation'; + +declare const gtag: (...args: any[]) => void; + +export function AnalyticsTracker() { + const pathname = usePathname(); + + useEffect(() => { + if (typeof window === 'undefined' || typeof gtag === 'undefined') return; + gtag('event', 'pageview', {event_label: pathname}); + }, [pathname]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const terminationEvent = 'onpagehide' in window ? 'pagehide' : 'unload'; + const handler = () => { + if (typeof gtag !== 'undefined') { + gtag('event', 'timing', { + event_label: 'JS Dependencies', + event: 'unload', + }); + } + }; + window.addEventListener(terminationEvent, handler); + return () => window.removeEventListener(terminationEvent, handler); + }, []); + + return null; +} + +export function ScrollRestoration() { + useEffect(() => { + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + if (isSafari) { + history.scrollRestoration = 'auto'; + } + }, []); + return null; +} diff --git a/src/app/community/[[...slug]]/page.tsx b/src/app/community/[[...slug]]/page.tsx new file mode 100644 index 00000000000..a0e3347bccb --- /dev/null +++ b/src/app/community/[[...slug]]/page.tsx @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Metadata} from 'next'; +import sidebarCommunity from '../../../sidebarCommunity.json'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import {collectSectionPaths} from 'lib/collectPaths'; +import {renderSectionPage, sectionPageMetadata} from '../../renderSectionPage'; + +interface PageProps { + params: Promise<{slug?: string[]}>; +} + +export async function generateStaticParams() { + const paths = await collectSectionPaths('community'); + return paths.map((slug) => ({slug})); +} + +export async function generateMetadata({params}: PageProps): Promise { + const {slug} = await params; + return sectionPageMetadata({ + section: 'community', + segments: ['community', ...(slug ?? [])], + }); +} + +export default async function CommunityPage({params}: PageProps) { + const {slug} = await params; + return renderSectionPage({ + section: 'community', + segments: ['community', ...(slug ?? [])], + routeTree: sidebarCommunity as RouteItem, + }); +} diff --git a/src/pages/500.js b/src/app/error.tsx similarity index 68% rename from src/pages/500.js rename to src/app/error.tsx index 552dcf77b16..7ae0c6a08b1 100644 --- a/src/pages/500.js +++ b/src/app/error.tsx @@ -5,21 +5,32 @@ * LICENSE file in the root directory of this source tree. */ -/* - * Copyright (c) Facebook, Inc. and its affiliates. - */ +'use client'; +import {useEffect} from 'react'; import {Page} from 'components/Layout/Page'; import {MDXComponents} from 'components/MDX/MDXComponents'; import sidebarLearn from '../sidebarLearn.json'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; const {Intro, MaxWidth, p: P, a: A} = MDXComponents; -export default function NotFound() { +export default function GlobalError({ + error, +}: { + error: Error & {digest?: string}; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + return ( diff --git a/src/app/errors/ErrorDecoderView.tsx b/src/app/errors/ErrorDecoderView.tsx new file mode 100644 index 00000000000..35afee5577e --- /dev/null +++ b/src/app/errors/ErrorDecoderView.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use client'; + +import {Page} from 'components/Layout/Page'; +import {useDeserializedMDX} from 'components/Layout/useDeserializedMDX'; +import {ErrorDecoderContext} from 'components/ErrorDecoderContext'; +import sidebarLearn from '../../sidebarLearn.json'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import type {ErrorDecoderData} from 'lib/loadErrorDecoderData'; + +interface ErrorDecoderViewProps { + data: ErrorDecoderData; + pathname: string; +} + +export function ErrorDecoderView({data, pathname}: ErrorDecoderViewProps) { + const {parsedContent} = useDeserializedMDX(data.content, data.toc); + return ( + + +
{parsedContent}
+
+
+ ); +} diff --git a/src/app/errors/[errorCode]/page.tsx b/src/app/errors/[errorCode]/page.tsx new file mode 100644 index 00000000000..48a864a11c8 --- /dev/null +++ b/src/app/errors/[errorCode]/page.tsx @@ -0,0 +1,30 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Metadata} from 'next'; +import {listErrorCodes, loadErrorDecoderData} from 'lib/loadErrorDecoderData'; +import {ErrorDecoderView} from '../ErrorDecoderView'; + +interface PageProps { + params: Promise<{errorCode: string}>; +} + +export async function generateStaticParams() { + const codes = await listErrorCodes(); + return codes.map((errorCode) => ({errorCode})); +} + +export async function generateMetadata({params}: PageProps): Promise { + const {errorCode} = await params; + return {title: `Minified React error #${errorCode}`}; +} + +export default async function ErrorDecoderPage({params}: PageProps) { + const {errorCode} = await params; + const data = await loadErrorDecoderData(errorCode); + return ; +} diff --git a/src/app/errors/page.tsx b/src/app/errors/page.tsx new file mode 100644 index 00000000000..5fec9bf2406 --- /dev/null +++ b/src/app/errors/page.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Metadata} from 'next'; +import {loadErrorDecoderData} from 'lib/loadErrorDecoderData'; +import {ErrorDecoderView} from './ErrorDecoderView'; + +export const metadata: Metadata = { + title: 'Minified Error Decoder', +}; + +export default async function ErrorDecoderIndex() { + const data = await loadErrorDecoderData(null); + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 00000000000..728b5fd96a5 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,219 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Metadata, Viewport} from 'next'; +import Script from 'next/script'; +import {siteConfig} from '../siteConfig'; +import {AnalyticsTracker, ScrollRestoration} from './clientEffects'; + +import '@docsearch/css'; +import '../styles/algolia.css'; +import '../styles/index.css'; +import '../styles/sandpack.css'; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: '#23272f', +}; + +export const metadata: Metadata = { + metadataBase: new URL( + `https://${ + siteConfig.languageCode === 'en' ? '' : siteConfig.languageCode + '.' + }react.dev` + ), + applicationName: 'React', + icons: { + icon: [ + {url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png'}, + {url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png'}, + ], + apple: [{url: '/apple-touch-icon.png', sizes: '180x180'}], + other: [ + {rel: 'mask-icon', url: '/safari-pinned-tab.svg', color: '#404756'}, + ], + }, + manifest: '/site.webmanifest', + // Items here render as ``. Property-style tags + // (``) like `fb:app_id` must be rendered directly + // in `` below, since Next's `metadata.other` only emits `name=`. + other: { + 'msapplication-TileColor': '#2b5797', + 'google-site-verification': 'sIlAGs48RulR4DdP95YSWNKZIEtCqQmRjzn-Zq-CcD0', + }, +}; + +const themeScript = ` +(function () { + try { + let logShown = false; + function setUwu(isUwu) { + try { + if (isUwu) { + localStorage.setItem('uwu', true); + document.documentElement.classList.add('uwu'); + if (!logShown) { + console.log('uwu mode! turn off with ?uwu=0'); + console.log('logo credit to @sawaratsuki1004 via https://github.com/SAWARATSUKI/KawaiiLogos'); + logShown = true; + } + } else { + localStorage.removeItem('uwu'); + document.documentElement.classList.remove('uwu'); + console.log('uwu mode off. turn on with ?uwu'); + } + } catch (err) { } + } + window.__setUwu = setUwu; + function checkQueryParam() { + const params = new URLSearchParams(window.location.search); + const value = params.get('uwu'); + switch(value) { + case '': + case 'true': + case '1': + return true; + case 'false': + case '0': + return false; + default: + return null; + } + } + function checkLocalStorage() { + try { + return localStorage.getItem('uwu') === 'true'; + } catch (err) { + return false; + } + } + const uwuQueryParam = checkQueryParam(); + if (uwuQueryParam != null) { + setUwu(uwuQueryParam); + } else if (checkLocalStorage()) { + document.documentElement.classList.add('uwu'); + } + } catch (err) { } +})(); + +(function () { + function setTheme(newTheme) { + window.__theme = newTheme; + if (newTheme === 'dark') { + document.documentElement.classList.add('dark'); + } else if (newTheme === 'light') { + document.documentElement.classList.remove('dark'); + } + } + + var preferredTheme; + try { + preferredTheme = localStorage.getItem('theme'); + } catch (err) { } + + window.__setPreferredTheme = function(newTheme) { + preferredTheme = newTheme; + setTheme(newTheme); + try { + localStorage.setItem('theme', newTheme); + } catch (err) { } + }; + + var initialTheme = preferredTheme; + var darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + if (!initialTheme) { + initialTheme = darkQuery.matches ? 'dark' : 'light'; + } + setTheme(initialTheme); + + darkQuery.addEventListener('change', function (e) { + if (!preferredTheme) { + setTheme(e.matches ? 'dark' : 'light'); + } + }); + + document.documentElement.classList.add( + window.navigator.platform.includes('Mac') + ? "platform-mac" + : "platform-win" + ); +})(); +`; + +const FONT_PRELOADS = [ + 'Source-Code-Pro-Regular.woff2', + 'Source-Code-Pro-Bold.woff2', + 'Optimistic_Display_W_Md.woff2', + 'Optimistic_Display_W_SBd.woff2', + 'Optimistic_Display_W_Bd.woff2', + 'Optimistic_Text_W_Md.woff2', + 'Optimistic_Text_W_Bd.woff2', + 'Optimistic_Text_W_Rg.woff2', + 'Optimistic_Text_W_It.woff2', +]; + +export default function RootLayout({children}: {children: React.ReactNode}) { + const gaId = process.env.NEXT_PUBLIC_GA_TRACKING_ID; + return ( + + + {/* RSS autodiscovery */} + + {/* Preconnect to Algolia DocSearch for faster first-open search */} + + {/* Facebook app id is a property-style meta tag and can't be expressed + via Next's `metadata.other`, which emits `name=` tags. */} + + {FONT_PRELOADS.map((file) => ( + + ))} + {gaId && ( +