Skip to content

Commit 8bf598b

Browse files
committed
fix: tighten App Router boundaries and metadata
1 parent 9f720f5 commit 8bf598b

26 files changed

Lines changed: 480 additions & 32313 deletions

.github/workflows/analyze.yml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,13 @@ jobs:
4242
key: ${{ runner.os }}-build-${{ env.cache-name }}
4343

4444
- name: Build next.js app
45-
# This project pins the webpack pipeline (see next.config.js): the custom
46-
# webpack config and Sandpack's raw-loader imports aren't Turbopack-ready,
47-
# and Next 16's `next build` defaults to Turbopack. Match the `build`/`analyze`
48-
# npm scripts by forcing `--webpack`.
49-
run: ./node_modules/.bin/next build --webpack
45+
# Match the production Turbopack build so route-level diagnostics reflect
46+
# the chunks users receive in production.
47+
run: ./node_modules/.bin/next build
5048

5149
# Measure the current build's bundle sizes (App Router-aware).
52-
# See scripts/analyzeBundle.mjs — reads build-manifest.json + .next/static
53-
# and sums gzipped sizes, since nextjs-bundle-analysis only understands the
54-
# Pages Router and reports 0 B for the App Router.
50+
# See scripts/analyzeBundle.mjs — reads build output and Turbopack's route
51+
# diagnostics, since nextjs-bundle-analysis only understands Pages Router.
5552
- name: Analyze bundle
5653
run: node scripts/analyzeBundle.mjs report
5754

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
src/content/**/*.md
2+
src/components/MDX/Sandpack/sandpack-rsc/sandbox-code/src/worker-bundle.dist.js

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"predev": "node scripts/buildRscWorker.mjs",
1010
"dev": "next dev",
1111
"prebuild:rsc": "node scripts/buildRscWorker.mjs",
12-
"build": "node scripts/buildRscWorker.mjs && next build && node --experimental-modules ./scripts/downloadFonts.mjs && node ./scripts/generateOgImages.mjs",
12+
"build": "node scripts/buildRscWorker.mjs && next build && node --experimental-modules ./scripts/downloadFonts.mjs && node ./scripts/generateOgImages.mjs && node ./scripts/validateMetadata.mjs",
1313
"lint": "eslint \"{src,plugins}/**/*.{js,jsx,ts,tsx}\" && eslint \"src/content/**/*.md\"",
1414
"lint:fix": "eslint --fix \"{src,plugins}/**/*.{js,jsx,ts,tsx}\" && eslint --fix \"src/content/**/*.md\"",
1515
"format:source": "prettier --config .prettierrc --write \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
@@ -25,6 +25,7 @@
2525
"check-all": "npm-run-all prettier lint:fix tsc rss",
2626
"rss": "node scripts/generateRss.js",
2727
"deadlinks": "node scripts/deadLinkChecker.js",
28+
"validate-metadata": "node scripts/validateMetadata.mjs",
2829
"copyright": "node scripts/copyright.js",
2930
"test:eslint-local-rules": "yarn --cwd eslint-local-rules test"
3031
},

scripts/analyzeBundle.mjs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,48 @@ function sumGroup(files) {
5959
return {raw, gzip, count: files.length};
6060
}
6161

62+
function addRouteStats(stats) {
63+
const routeStatsFile = path.join(
64+
nextDir,
65+
'diagnostics',
66+
'route-bundle-stats.json'
67+
);
68+
if (!fs.existsSync(routeStatsFile)) return;
69+
70+
const routeStats = JSON.parse(fs.readFileSync(routeStatsFile, 'utf8'));
71+
const representativeRoutes = {
72+
'First-load JS: Home': '/',
73+
'First-load JS: Learn': '/learn/[[...slug]]',
74+
'First-load JS: Reference': '/reference/[[...slug]]',
75+
'First-load JS: Community': '/community/[[...slug]]',
76+
'First-load JS: Blog': '/blog/[[...slug]]',
77+
};
78+
79+
for (const [label, route] of Object.entries(representativeRoutes)) {
80+
const entry = routeStats.find((item) => item.route === route);
81+
if (!entry) continue;
82+
const files = entry.firstLoadChunkPaths
83+
.map((file) => path.join(root, file))
84+
.filter((file) => fs.existsSync(file));
85+
stats[label] = sumGroup(files);
86+
}
87+
}
88+
89+
function addHtmlStats(stats) {
90+
const representativePages = {
91+
'HTML: Home': 'index.html',
92+
'HTML: Effects guide': 'learn/synchronizing-with-effects.html',
93+
'HTML: useState': 'reference/react/useState.html',
94+
};
95+
96+
for (const [label, file] of Object.entries(representativePages)) {
97+
const fullPath = path.join(nextDir, 'server', 'app', file);
98+
if (fs.existsSync(fullPath)) {
99+
stats[label] = sumGroup([fullPath]);
100+
}
101+
}
102+
}
103+
62104
function report() {
63105
const manifest = JSON.parse(
64106
fs.readFileSync(path.join(nextDir, 'build-manifest.json'), 'utf8')
@@ -73,7 +115,7 @@ function report() {
73115
const jsFiles = walk(path.join(nextDir, 'static', 'chunks')).filter((f) =>
74116
f.endsWith('.js')
75117
);
76-
const cssFiles = walk(path.join(nextDir, 'static', 'css')).filter((f) =>
118+
const cssFiles = walk(path.join(nextDir, 'static')).filter((f) =>
77119
f.endsWith('.css')
78120
);
79121

@@ -82,6 +124,8 @@ function report() {
82124
'Total JS': sumGroup(jsFiles),
83125
'Total CSS': sumGroup(cssFiles),
84126
};
127+
addRouteStats(stats);
128+
addHtmlStats(stats);
85129

86130
fs.mkdirSync(analyzeDir, {recursive: true});
87131
fs.writeFileSync(statsFile, JSON.stringify(stats, null, 2));
@@ -110,7 +154,9 @@ function isNewFormat(obj) {
110154
const vals = obj && typeof obj === 'object' ? Object.values(obj) : [];
111155
return (
112156
vals.length > 0 &&
113-
vals.every((v) => v && typeof v.gzip === 'number' && typeof v.count === 'number')
157+
vals.every(
158+
(v) => v && typeof v.gzip === 'number' && typeof v.count === 'number'
159+
)
114160
);
115161
}
116162

scripts/validateMetadata.mjs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import fs from 'fs';
9+
import path from 'path';
10+
import {fileURLToPath} from 'url';
11+
12+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13+
const appDir = path.join(root, '.next', 'server', 'app');
14+
15+
function walk(dir) {
16+
return fs.readdirSync(dir, {withFileTypes: true}).flatMap((entry) => {
17+
const file = path.join(dir, entry.name);
18+
return entry.isDirectory() ? walk(file) : [file];
19+
});
20+
}
21+
22+
function getHead(html) {
23+
const end = html.indexOf('</head>');
24+
return end === -1 ? '' : html.slice(0, end + 7);
25+
}
26+
27+
function getAttribute(tag, name) {
28+
return tag.match(new RegExp(`${name}="([^"]*)"`))?.[1];
29+
}
30+
31+
const pages = walk(appDir).filter(
32+
(file) =>
33+
file.endsWith('.html') &&
34+
!file.includes('[') &&
35+
!file.endsWith('_global-error.html')
36+
);
37+
const errors = [];
38+
39+
for (const file of pages) {
40+
const head = getHead(fs.readFileSync(file, 'utf8'));
41+
const relative = path.relative(appDir, file);
42+
const title = head.match(/<title>([^<]*)<\/title>/)?.[1];
43+
44+
if (!title || title === ' – React') {
45+
errors.push(`${relative}: missing page title`);
46+
}
47+
48+
if (file.endsWith('_not-found.html')) {
49+
if (!head.includes('<meta name="robots" content="noindex"')) {
50+
errors.push(`${relative}: missing noindex metadata`);
51+
}
52+
continue;
53+
}
54+
55+
const required = [
56+
'rel="canonical"',
57+
'hrefLang="x-default"',
58+
'property="og:title"',
59+
'property="og:url"',
60+
'property="og:image"',
61+
'name="twitter:card"',
62+
'name="twitter:title"',
63+
'name="twitter:image"',
64+
];
65+
for (const marker of required) {
66+
if (!head.includes(marker)) {
67+
errors.push(`${relative}: missing ${marker}`);
68+
}
69+
}
70+
71+
const ogImageTag = head.match(/<meta property="og:image"[^>]*>/)?.[0];
72+
const ogImage = ogImageTag && getAttribute(ogImageTag, 'content');
73+
if (ogImage) {
74+
const imagePath = path.join(root, 'public', new URL(ogImage).pathname);
75+
if (!fs.existsSync(imagePath)) {
76+
errors.push(`${relative}: missing OG image ${ogImage}`);
77+
}
78+
}
79+
}
80+
81+
if (errors.length > 0) {
82+
console.error(errors.join('\n'));
83+
process.exit(1);
84+
}
85+
86+
console.log(`Validated metadata for ${pages.length} prerendered pages.`);

src/app/DocsPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export async function DocsPage({
3333
routeTree={routeTree}
3434
meta={data.meta}
3535
section={section}
36-
pathname={pathname}>
36+
pathname={pathname}
37+
showCopyPage>
3738
{children ?? content}
3839
</Page>
3940
);

src/app/error.tsx

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,11 @@
88
'use client';
99

1010
import {useEffect} from 'react';
11-
import {Page} from 'components/Layout/Page';
12-
import {MDXComponents} from 'components/MDX/MDXComponents';
13-
import sidebarLearn from '../sidebarLearn.json';
14-
import type {RouteItem} from 'components/Layout/getRouteMeta';
15-
16-
const {Intro, MaxWidth, p: P, a: A} = MDXComponents;
11+
import Link from 'next/link';
1712

1813
export default function GlobalError({
1914
error,
15+
reset,
2016
}: {
2117
error: Error & {digest?: string};
2218
reset: () => void;
@@ -26,24 +22,37 @@ export default function GlobalError({
2622
}, [error]);
2723

2824
return (
29-
<Page
30-
toc={[]}
31-
routeTree={sidebarLearn as RouteItem}
32-
section="unknown"
33-
pathname="/500"
34-
meta={{title: 'Something Went Wrong'}}>
35-
<MaxWidth>
36-
<Intro>
37-
<P>Something went very wrong.</P>
38-
<P>Sorry about that.</P>
39-
<P>
40-
If you’d like, please{' '}
41-
<A href="https://github.com/reactjs/react.dev/issues/new">
42-
report a bug.
43-
</A>
44-
</P>
45-
</Intro>
46-
</MaxWidth>
47-
</Page>
25+
<main className="min-h-screen px-5 py-16 sm:px-12">
26+
<div className="max-w-4xl mx-auto">
27+
<h1 className="font-display text-4xl font-bold text-primary dark:text-primary-dark">
28+
Something Went Wrong
29+
</h1>
30+
<div className="mt-6 font-display text-xl leading-relaxed text-primary dark:text-primary-dark">
31+
<p>Something went very wrong. Sorry about that.</p>
32+
<p className="mt-4">
33+
You can try again or{' '}
34+
<a
35+
className="text-link dark:text-link-dark underline"
36+
href="https://github.com/reactjs/react.dev/issues/new">
37+
report a bug
38+
</a>
39+
.
40+
</p>
41+
</div>
42+
<div className="flex gap-3 mt-8">
43+
<button
44+
type="button"
45+
onClick={reset}
46+
className="py-2 px-4 rounded-full bg-link text-white font-bold">
47+
Try again
48+
</button>
49+
<Link
50+
href="/"
51+
className="py-2 px-4 rounded-full border border-border dark:border-border-dark font-bold text-primary dark:text-primary-dark">
52+
Go home
53+
</Link>
54+
</div>
55+
</div>
56+
</main>
4857
);
4958
}

src/app/errors/[errorCode]/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type {Metadata} from 'next';
9+
import {buildPageMetadata} from 'lib/buildPageMetadata';
910
import {listErrorCodes, loadErrorDecoderData} from 'lib/loadErrorDecoderData';
1011
import {ErrorDecoderView} from '../ErrorDecoderView';
1112

@@ -20,7 +21,13 @@ export async function generateStaticParams() {
2021

2122
export async function generateMetadata({params}: PageProps): Promise<Metadata> {
2223
const {errorCode} = await params;
23-
return {title: `Minified React error #${errorCode}`};
24+
const data = await loadErrorDecoderData(errorCode);
25+
return buildPageMetadata({
26+
data,
27+
pathname: `/errors/${errorCode}`,
28+
section: 'unknown',
29+
title: `Minified React error #${errorCode}`,
30+
});
2431
}
2532

2633
export default async function ErrorDecoderPage({params}: PageProps) {

src/app/errors/page.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66
*/
77

88
import type {Metadata} from 'next';
9+
import {buildPageMetadata} from 'lib/buildPageMetadata';
910
import {loadErrorDecoderData} from 'lib/loadErrorDecoderData';
1011
import {ErrorDecoderView} from './ErrorDecoderView';
1112

12-
export const metadata: Metadata = {
13-
title: 'Minified Error Decoder',
14-
};
13+
export async function generateMetadata(): Promise<Metadata> {
14+
const data = await loadErrorDecoderData(null);
15+
return buildPageMetadata({
16+
data,
17+
pathname: '/errors',
18+
section: 'unknown',
19+
title: 'Minified Error Decoder',
20+
});
21+
}
1522

1623
export default async function ErrorDecoderIndex() {
1724
const data = await loadErrorDecoderData(null);

src/app/not-found.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
'use client';
9-
108
import {Page} from 'components/Layout/Page';
11-
import {MDXComponents} from 'components/MDX/MDXComponents';
9+
import Intro from 'components/MDX/Intro';
10+
import Link from 'components/MDX/Link';
1211
import sidebarLearn from '../sidebarLearn.json';
1312
import type {RouteItem} from 'components/Layout/getRouteMeta';
13+
import type {Metadata} from 'next';
1414

15-
const {Intro, MaxWidth, p: P, a: A} = MDXComponents;
15+
export const metadata: Metadata = {
16+
title: 'Not Found – React',
17+
};
1618

1719
export default function NotFound() {
1820
return (
@@ -22,19 +24,19 @@ export default function NotFound() {
2224
routeTree={sidebarLearn as RouteItem}
2325
section="unknown"
2426
pathname="/404">
25-
<MaxWidth>
27+
<div className="max-w-4xl ms-0 2xl:mx-auto">
2628
<Intro>
27-
<P>This page doesn’t exist.</P>
28-
<P>
29+
<p>This page doesn’t exist.</p>
30+
<p>
2931
If this is a mistake{', '}
30-
<A href="https://github.com/reactjs/react.dev/issues/new">
32+
<Link href="https://github.com/reactjs/react.dev/issues/new">
3133
let us know
32-
</A>
34+
</Link>
3335
{', '}
3436
and we will try to fix it!
35-
</P>
37+
</p>
3638
</Intro>
37-
</MaxWidth>
39+
</div>
3840
</Page>
3941
);
4042
}

0 commit comments

Comments
 (0)