Optimize bundle size for appDir (#42252)
- Ensure React / other deps are deduped against main-app bundle - Only require web-vitals when it's needed - Move warnOnce into separate file as it's not tree shaken - Add create-next-app with bundle analyzer <details> <summary>13.0.0 (react-dom deduping bug)</summary> <img width="1912" alt="13-0-0" src="https://user-images.githubusercontent.com/6324199/199067942-d2394ffa-fc1c-4606-94f4-b489ef959a9b.png"> </details> <details> <summary>Canary (react-dom deduping bug fixed)</summary> <img width="1912" alt="current-canary-branch" src="https://user-images.githubusercontent.com/6324199/199067999-786b523e-b4f0-4044-8d2e-acaa0386771a.png"> </details> <details> <summary>This PR</summary> <img width="1912" alt="pr-changes" src="https://user-images.githubusercontent.com/6324199/199068060-2fdb6e11-8b94-4683-9b8e-65bb6faab34a.png"> </details> ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm build && pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
This commit is contained in:
parent
848bb3af73
commit
8e3586d2cc
15 changed files with 414 additions and 19 deletions
|
@ -984,7 +984,10 @@ export default async function build(
|
|||
value,
|
||||
]
|
||||
} else {
|
||||
clientEntry[key] = value
|
||||
clientEntry[key] = {
|
||||
dependOn: [CLIENT_STATIC_FILES_RUNTIME_MAIN_APP],
|
||||
import: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import ReactDOMClient from 'react-dom/client'
|
|||
import React, { use } from 'react'
|
||||
import { createFromReadableStream } from 'next/dist/compiled/react-server-dom-webpack/client'
|
||||
|
||||
import measureWebVitals from './performance-relayer'
|
||||
import { HeadManagerContext } from '../shared/lib/head-manager-context'
|
||||
import { GlobalLayoutRouterContext } from '../shared/lib/app-router-context'
|
||||
|
||||
|
@ -155,7 +154,9 @@ const StrictModeIfEnabled = process.env.__NEXT_STRICT_MODE_APP
|
|||
|
||||
function Root({ children }: React.PropsWithChildren<{}>): React.ReactElement {
|
||||
React.useEffect(() => {
|
||||
measureWebVitals()
|
||||
if (process.env.__NEXT_ANALYTICS_ID) {
|
||||
require('./performance-relayer-app')()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (process.env.__NEXT_TEST_MODE) {
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
ImageLoaderPropsWithConfig,
|
||||
} from '../shared/lib/image-config'
|
||||
import { ImageConfigContext } from '../shared/lib/image-config-context'
|
||||
import { warnOnce } from '../shared/lib/utils'
|
||||
import { warnOnce } from '../shared/lib/utils/warn-once'
|
||||
// @ts-ignore - This is replaced by webpack alias
|
||||
import defaultLoader from 'next/dist/shared/lib/image-loader'
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from '../../shared/lib/image-config'
|
||||
import { useIntersection } from '../use-intersection'
|
||||
import { ImageConfigContext } from '../../shared/lib/image-config-context'
|
||||
import { warnOnce } from '../../shared/lib/utils'
|
||||
import { warnOnce } from '../../shared/lib/utils/warn-once'
|
||||
import { normalizePathTrailingSlash } from '../normalize-trailing-slash'
|
||||
|
||||
function normalizeSrc(src: string): string {
|
||||
|
|
103
packages/next/client/performance-relayer-app.ts
Normal file
103
packages/next/client/performance-relayer-app.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
/* global location */
|
||||
import type { Metric, ReportCallback } from 'next/dist/compiled/web-vitals'
|
||||
|
||||
// copied to prevent pulling in un-necessary utils
|
||||
const WEB_VITALS = ['CLS', 'FCP', 'FID', 'INP', 'LCP', 'TTFB']
|
||||
|
||||
const initialHref = location.href
|
||||
let isRegistered = false
|
||||
let userReportHandler: ReportCallback | undefined
|
||||
type Attribution = typeof WEB_VITALS[number]
|
||||
|
||||
function onReport(metric: Metric): void {
|
||||
if (userReportHandler) {
|
||||
userReportHandler(metric)
|
||||
}
|
||||
|
||||
// This code is not shipped, executed, or present in the client-side
|
||||
// JavaScript bundle unless explicitly enabled in your application.
|
||||
//
|
||||
// When this feature is enabled, we'll make it very clear by printing a
|
||||
// message during the build (`next build`).
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
// This field is empty unless you explicitly configure it:
|
||||
process.env.__NEXT_ANALYTICS_ID
|
||||
) {
|
||||
const body: Record<string, string> = {
|
||||
dsn: process.env.__NEXT_ANALYTICS_ID,
|
||||
id: metric.id,
|
||||
page: window.__NEXT_DATA__?.page,
|
||||
href: initialHref,
|
||||
event_name: metric.name,
|
||||
value: metric.value.toString(),
|
||||
speed:
|
||||
'connection' in navigator &&
|
||||
(navigator as any)['connection'] &&
|
||||
'effectiveType' in (navigator as any)['connection']
|
||||
? ((navigator as any)['connection']['effectiveType'] as string)
|
||||
: '',
|
||||
}
|
||||
|
||||
const blob = new Blob([new URLSearchParams(body).toString()], {
|
||||
// This content type is necessary for `sendBeacon`:
|
||||
type: 'application/x-www-form-urlencoded',
|
||||
})
|
||||
const vitalsUrl = 'https://vitals.vercel-insights.com/v1/vitals'
|
||||
// Navigator has to be bound to ensure it does not error in some browsers
|
||||
// https://xgwang.me/posts/you-may-not-know-beacon/#it-may-throw-error%2C-be-sure-to-catch
|
||||
const send = navigator.sendBeacon && navigator.sendBeacon.bind(navigator)
|
||||
|
||||
function fallbackSend() {
|
||||
fetch(vitalsUrl, {
|
||||
body: blob,
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
// console.error is used here as when the fetch fails it does not affect functioning of the app
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
try {
|
||||
// If send is undefined it'll throw as well. This reduces output code size.
|
||||
send!(vitalsUrl, blob) || fallbackSend()
|
||||
} catch (err) {
|
||||
fallbackSend()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default (onPerfEntry?: ReportCallback): void => {
|
||||
if (process.env.__NEXT_ANALYTICS_ID) {
|
||||
// Update function if it changes:
|
||||
userReportHandler = onPerfEntry
|
||||
|
||||
// Only register listeners once:
|
||||
if (isRegistered) {
|
||||
return
|
||||
}
|
||||
isRegistered = true
|
||||
|
||||
const attributions: Attribution[] | undefined = process.env
|
||||
.__NEXT_WEB_VITALS_ATTRIBUTION as any
|
||||
|
||||
for (const webVital of WEB_VITALS) {
|
||||
try {
|
||||
let mod: any
|
||||
|
||||
if (process.env.__NEXT_HAS_WEB_VITALS_ATTRIBUTION) {
|
||||
if (attributions?.includes(webVital)) {
|
||||
mod = require('next/dist/compiled/web-vitals-attribution')
|
||||
}
|
||||
}
|
||||
if (!mod) {
|
||||
mod = require('next/dist/compiled/web-vitals')
|
||||
}
|
||||
mod[`on${webVital}`](onReport)
|
||||
} catch (err) {
|
||||
// Do nothing if the module fails to load
|
||||
console.warn(`Failed to track ${webVital} web-vital`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import Effect from './side-effect'
|
|||
import { AmpStateContext } from './amp-context'
|
||||
import { HeadManagerContext } from './head-manager-context'
|
||||
import { isInAmpMode } from './amp-mode'
|
||||
import { warnOnce } from './utils'
|
||||
import { warnOnce } from './utils/warn-once'
|
||||
|
||||
type WithInAmpMode = {
|
||||
inAmpMode?: boolean
|
||||
|
|
|
@ -400,19 +400,6 @@ export async function loadGetInitialProps<
|
|||
return props
|
||||
}
|
||||
|
||||
let warnOnce = (_: string) => {}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const warnings = new Set<string>()
|
||||
warnOnce = (msg: string) => {
|
||||
if (!warnings.has(msg)) {
|
||||
console.warn(msg)
|
||||
}
|
||||
warnings.add(msg)
|
||||
}
|
||||
}
|
||||
|
||||
export { warnOnce }
|
||||
|
||||
export const SP = typeof performance !== 'undefined'
|
||||
export const ST =
|
||||
SP &&
|
||||
|
|
12
packages/next/shared/lib/utils/warn-once.ts
Normal file
12
packages/next/shared/lib/utils/warn-once.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
let warnOnce = (_: string) => {}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const warnings = new Set<string>()
|
||||
warnOnce = (msg: string) => {
|
||||
if (!warnings.has(msg)) {
|
||||
console.warn(msg)
|
||||
}
|
||||
warnings.add(msg)
|
||||
}
|
||||
}
|
||||
|
||||
export { warnOnce }
|
4
test/e2e/app-dir/create-next-app-template/.vscode/settings.json
vendored
Normal file
4
test/e2e/app-dir/create-next-app-template/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"typescript.tsdk": "./node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
26
test/e2e/app-dir/create-next-app-template/app/globals.css
Normal file
26
test/e2e/app-dir/create-next-app-template/app/globals.css
Normal file
|
@ -0,0 +1,26 @@
|
|||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
color: white;
|
||||
background: black;
|
||||
}
|
||||
}
|
18
test/e2e/app-dir/create-next-app-template/app/layout.tsx
Normal file
18
test/e2e/app-dir/create-next-app-template/app/layout.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import './globals.css'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
146
test/e2e/app-dir/create-next-app-template/app/page.module.css
Normal file
146
test/e2e/app-dir/create-next-app-template/app/page.module.css
Normal file
|
@ -0,0 +1,146 @@
|
|||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid #eaeaea;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.title a {
|
||||
text-decoration: none;
|
||||
color: #0070f3;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.title {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #aaaaaa 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
.title a {
|
||||
background: linear-gradient(180deg, #0070f3 0%, #0153af 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
.card,
|
||||
.footer {
|
||||
border-color: #222;
|
||||
}
|
||||
.code {
|
||||
background: #111;
|
||||
}
|
||||
.logo img {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
57
test/e2e/app-dir/create-next-app-template/app/page.tsx
Normal file
57
test/e2e/app-dir/create-next-app-template/app/page.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import Image from 'next/image'
|
||||
import styles from './page.module.css'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js 13!</a>
|
||||
</h1>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{' '}
|
||||
<code className={styles.code}>app/page.tsx</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://beta.nextjs.org/docs" className={styles.card}>
|
||||
<h2>Documentation →</h2>
|
||||
<p>Find in-depth information about Next.js 13</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/canary/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Examples →</h2>
|
||||
<p>Explore the Next.js 13 playground.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/templates/next.js/app-directory?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Deploy →</h2>
|
||||
<p>Deploy your Next.js site to a public URL with Vercel.</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{' '}
|
||||
<span className={styles.logo}>
|
||||
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
|
||||
</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
13
test/e2e/app-dir/create-next-app-template/next.config.js
Normal file
13
test/e2e/app-dir/create-next-app-template/next.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
enabled: process.env.ANALYZE === 'true',
|
||||
})
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
}
|
||||
|
||||
// module.exports = nextConfig
|
||||
module.exports = withBundleAnalyzer(nextConfig)
|
25
test/e2e/app-dir/create-next-app-template/tsconfig.json
Normal file
25
test/e2e/app-dir/create-next-app-template/tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
Reference in a new issue