diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 83e7f2e8a9c..fbd4af15450 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -42,13 +42,18 @@ jobs: key: ${{ runner.os }}-build-${{ env.cache-name }} - name: Build next.js app - # change this if your site requires a custom build command - run: ./node_modules/.bin/next build - - # Here's the first place where next-bundle-analysis' own script is used - # This step pulls the raw bundle stats for the current bundle + # This project pins the webpack pipeline (see next.config.js): the custom + # webpack config and Sandpack's raw-loader imports aren't Turbopack-ready, + # and Next 16's `next build` defaults to Turbopack. Match the `build`/`analyze` + # npm scripts by forcing `--webpack`. + run: ./node_modules/.bin/next build --webpack + + # Measure the current build's bundle sizes (App Router-aware). + # See scripts/analyzeBundle.mjs — reads build-manifest.json + .next/static + # and sums gzipped sizes, since nextjs-bundle-analysis only understands the + # Pages Router and reports 0 B for the App Router. - name: Analyze bundle - run: npx -p nextjs-bundle-analysis@0.5.0 report + run: node scripts/analyzeBundle.mjs report - name: Upload bundle uses: actions/upload-artifact@v4 @@ -65,22 +70,12 @@ jobs: name: bundle_analysis.json path: .next/analyze/base/bundle - # And here's the second place - this runs after we have both the current and - # base branch bundle stats, and will compare them to determine what changed. - # There are two configurable arguments that come from package.json: - # - # - budget: optional, set a budget (bytes) against which size changes are measured - # it's set to 350kb here by default, as informed by the following piece: - # https://infrequently.org/2021/03/the-performance-inequality-gap/ - # - # - red-status-percentage: sets the percent size increase where you get a red - # status indicator, defaults to 20% - # - # Either of these arguments can be changed or removed by editing the `nextBundleAnalysis` - # entry in your package.json file. + # Compare the current build against the base-branch stats downloaded above + # and write the Markdown comment body. Degrades gracefully when the base + # branch has no stats yet (or still has the old format). - name: Compare with base branch bundle if: success() && github.event.number - run: ls -laR .next/analyze/base && npx -p nextjs-bundle-analysis compare + run: node scripts/analyzeBundle.mjs compare - name: Upload analysis comment uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index ed9efe38d02..8d75c97bac9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ public/rss.xml # claude local settings .claude/*.local.* .claude/react/ +.claude/launch.json # worktrees .worktrees/ 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 97bc1432c5e..bbfb383c0b5 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 && node ./scripts/generateOgImages.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 && node ./scripts/generateOgImages.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-collapsed": "4.0.4", - "react-dom": "^19.0.0", + "react": "19.2.7", + "react-collapsed": "^4.2.0", + "react-dom": "19.2.7", "remark-frontmatter": "^4.0.1", "remark-gfm": "^3.0.1" }, @@ -57,10 +56,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", @@ -70,7 +70,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", @@ -110,16 +110,15 @@ "webpack-bundle-analyzer": "^4.5.0" }, "engines": { - "node": ">=16.8.0" - }, - "nextBundleAnalysis": { - "budget": null, - "budgetPercentIncreaseRed": 10, - "showDetails": true + "node": ">=20.9.0" }, "lint-staged": { "*.{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/scripts/analyzeBundle.mjs b/scripts/analyzeBundle.mjs new file mode 100644 index 00000000000..3c213b629a4 --- /dev/null +++ b/scripts/analyzeBundle.mjs @@ -0,0 +1,169 @@ +/** + * 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. + */ + +/* + * App Router-aware bundle-size analysis. + * + * Modeled on Next.js's own `next-stats-action`: sum the gzipped sizes of + * groups of build-output files and compare a PR build against the base branch. + * Unlike `nextjs-bundle-analysis`, this reads the real build output + * (`build-manifest.json` + `.next/static`) rather than the Pages-Router-only + * `build-manifest.json.pages`, which the App Router leaves empty. + * + * Usage: + * node scripts/analyzeBundle.mjs report # writes the current build's stats + * node scripts/analyzeBundle.mjs compare # diffs against the base-branch stats + * + * `report` -> .next/analyze/__bundle_analysis.json (uploaded as an artifact) + * `compare` -> .next/analyze/__bundle_analysis_comment.txt (posted by + * analyze_comment.yml). The base-branch artifact is expected under + * .next/analyze/base/bundle/ (downloaded by analyze.yml). + */ + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; +import {fileURLToPath} from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); +const nextDir = path.join(root, '.next'); +const analyzeDir = path.join(nextDir, 'analyze'); +const statsFile = path.join(analyzeDir, '__bundle_analysis.json'); +const baseDir = path.join(analyzeDir, 'base', 'bundle'); +const commentFile = path.join(analyzeDir, '__bundle_analysis_comment.txt'); + +function walk(dir) { + if (!fs.existsSync(dir)) return []; + let out = []; + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) out = out.concat(walk(p)); + else out.push(p); + } + return out; +} + +function sumGroup(files) { + let raw = 0; + let gzip = 0; + for (const file of files) { + const buf = fs.readFileSync(file); + raw += buf.length; + gzip += zlib.gzipSync(buf).length; + } + return {raw, gzip, count: files.length}; +} + +function report() { + const manifest = JSON.parse( + fs.readFileSync(path.join(nextDir, 'build-manifest.json'), 'utf8') + ); + // The shared bundle that loads on every page (App Router: rootMainFiles). + const globalFiles = [ + ...(manifest.rootMainFiles || []), + ...(manifest.polyfillFiles || []), + ] + .map((f) => path.join(nextDir, f)) + .filter((f) => fs.existsSync(f)); + const jsFiles = walk(path.join(nextDir, 'static', 'chunks')).filter((f) => + f.endsWith('.js') + ); + const cssFiles = walk(path.join(nextDir, 'static', 'css')).filter((f) => + f.endsWith('.css') + ); + + const stats = { + 'Global (loads on every page)': sumGroup(globalFiles), + 'Total JS': sumGroup(jsFiles), + 'Total CSS': sumGroup(cssFiles), + }; + + fs.mkdirSync(analyzeDir, {recursive: true}); + fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2)); + console.log('Wrote', path.relative(root, statsFile)); + for (const [name, v] of Object.entries(stats)) { + console.log(` ${name}: ${formatBytes(v.gzip)} gzip (${v.count} files)`); + } +} + +function formatBytes(bytes) { + if (Math.abs(bytes) >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } + return `${(bytes / 1024).toFixed(2)} KB`; +} + +function formatDelta(cur, base) { + const d = cur - base; + if (d === 0) return 'no change'; + return `${d > 0 ? '🔺 +' : '🟢 -'}${formatBytes(Math.abs(d))}`; +} + +// New format: every value is {raw, gzip, count}. The old nextjs-bundle-analysis +// artifact was {"/_app": {raw, gzip}, "__global": {...}} with no `count`. +function isNewFormat(obj) { + const vals = obj && typeof obj === 'object' ? Object.values(obj) : []; + return ( + vals.length > 0 && + vals.every((v) => v && typeof v.gzip === 'number' && typeof v.count === 'number') + ); +} + +function loadBaseStats() { + if (!fs.existsSync(baseDir)) return null; + const jsons = fs.readdirSync(baseDir).filter((f) => f.endsWith('.json')); + if (jsons.length === 0) return null; + try { + return JSON.parse(fs.readFileSync(path.join(baseDir, jsons[0]), 'utf8')); + } catch { + return null; + } +} + +function compare() { + const cur = JSON.parse(fs.readFileSync(statsFile, 'utf8')); + const base = loadBaseStats(); + + let md; + if (base && isNewFormat(base)) { + md = + '| Metric | Size (gzip) | Change vs base |\n|---|---|---|\n' + + Object.entries(cur) + .map(([name, v]) => { + const b = base[name]; + const change = b ? formatDelta(v.gzip, b.gzip) : '— (new)'; + return `| ${name} | ${formatBytes(v.gzip)} | ${change} |`; + }) + .join('\n') + + '\n'; + } else { + md = + '_No comparable base-branch data yet — the base branch has not produced ' + + 'stats in this format. Showing current sizes only; deltas will appear on ' + + 'the next run after this lands on the base branch._\n\n' + + '| Metric | Size (gzip) |\n|---|---|\n' + + Object.entries(cur) + .map(([name, v]) => `| ${name} | ${formatBytes(v.gzip)} |`) + .join('\n') + + '\n'; + } + + fs.mkdirSync(analyzeDir, {recursive: true}); + fs.writeFileSync(commentFile, md); + console.log(md); +} + +const mode = process.argv[2]; +if (mode === 'report') { + report(); +} else if (mode === 'compare') { + compare(); +} else { + console.error('Usage: node scripts/analyzeBundle.mjs '); + process.exit(1); +} 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..edc28b0b999 --- /dev/null +++ b/src/app/community/[[...slug]]/page.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. + */ + +import type {Metadata} from 'next'; +import sidebarCommunity from '../../../sidebarCommunity.json'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import {collectSectionPaths} from 'lib/collectPaths'; +import { + renderSectionContent, + 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 renderSectionContent({ + section: 'community', + segments: ['community', ...(slug ?? [])], + routeTree: sidebarCommunity as RouteItem, + }); +} diff --git a/src/app/community/layout.tsx b/src/app/community/layout.tsx new file mode 100644 index 00000000000..81cdaa2ec02 --- /dev/null +++ b/src/app/community/layout.tsx @@ -0,0 +1,43 @@ +/** + * 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 * as React from 'react'; +import {usePathname} from 'next/navigation'; +import {SidebarNav} from 'components/Layout/SidebarNav'; +import {TopNav} from 'components/Layout/TopNav'; +import {getRouteMeta} from 'components/Layout/getRouteMeta'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; +import sidebarCommunity from '../../sidebarCommunity.json'; + +export default function CommunityLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname() || '/'; + const routeTree = sidebarCommunity as RouteItem; + const {breadcrumbs} = getRouteMeta(pathname, routeTree); + return ( + <> + +
+
+
+ +
+
+ {children} +
+ + ); +} diff --git a/src/app/community/not-found.tsx b/src/app/community/not-found.tsx new file mode 100644 index 00000000000..aed82f8fca2 --- /dev/null +++ b/src/app/community/not-found.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 sidebarCommunity from '../../sidebarCommunity.json'; +import {NotFoundContent} from 'components/Layout/NotFoundContent'; +import type {RouteItem} from 'components/Layout/getRouteMeta'; + +export default function NotFound() { + return ( + + ); +} 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..a77395cc904 --- /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 && ( +