perf: replace zod with superstruct (#56083)

This PR replaces the usage of Zod in the App Router in favour of a smaller validation library. We don't use much of zod's more advanced capabilities so it doesn't make sense to import all of it on the server at the moment.

Also added some unit tests.

This results in a 44kb win, which will impact cold boot

Before:
<img width="594" alt="CleanShot 2023-09-27 at 13 34 09@2x" src="https://github.com/vercel/next.js/assets/11064311/43cc7687-947d-40a1-9e1e-c2a60caf53c0">
After:
<img width="564" alt="CleanShot 2023-09-27 at 13 34 48@2x" src="https://github.com/vercel/next.js/assets/11064311/c3d3f5d6-e1f6-4969-bd11-dcd191d34ce6">


<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the PR.
- Read the Docs Contribution Guide to ensure your contribution follows the docs guidelines: https://nextjs.org/docs/community/contribution-guide

### Adding or Updating Examples

- The "examples guidelines" are followed from our contributing doc https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
- Make sure the linting passes by running `pnpm build && pnpm lint`. See https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See: https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. (A discussion must be opened, see https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added (https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to understand the PR)
- When linking to a Slack thread, you might want to share details of the conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->
This commit is contained in:
Jimmy Lai 2023-09-28 11:05:36 +02:00 committed by GitHub
parent 3b0ee4e5f8
commit c2fd08063c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 166 additions and 87 deletions

View file

@ -1,4 +1,5 @@
/// <reference types="./types/global" />
/// <reference types="./types/compiled" />
/// <reference path="./dist/styled-jsx/types/global.d.ts" />
/// <reference path="./amp.d.ts" />
/// <reference path="./app.d.ts" />

View file

@ -96,8 +96,7 @@
"caniuse-lite": "^1.0.30001406",
"postcss": "8.4.14",
"styled-jsx": "5.1.1",
"watchpack": "2.4.0",
"zod": "3.21.4"
"watchpack": "2.4.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@ -296,6 +295,7 @@
"string-hash": "1.1.3",
"string_decoder": "1.3.0",
"strip-ansi": "6.0.0",
"superstruct": "1.0.3",
"tar": "6.1.15",
"taskr": "1.1.0",
"terser": "5.14.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"name":"superstruct","main":"index.cjs","license":"MIT"}

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 Colin McDonnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"name":"zod","main":"index.js","author":"Colin McDonnell <colin@colinhacks.com>","license":"MIT"}

View file

@ -1,5 +1,6 @@
import { FlightRouterState } from './types'
import { flightRouterStateSchema } from './types'
import { assert } from 'next/dist/compiled/superstruct'
export function parseAndValidateFlightRouterState(
stateHeader: string | string[] | undefined
@ -23,9 +24,9 @@ export function parseAndValidateFlightRouterState(
}
try {
return flightRouterStateSchema.parse(
JSON.parse(decodeURIComponent(stateHeader))
)
const state = JSON.parse(decodeURIComponent(stateHeader))
assert(state, flightRouterStateSchema)
return state
} catch {
throw new Error('The router state header was sent but could not be parsed.')
}

View file

@ -0,0 +1,72 @@
import { flightRouterStateSchema } from './types'
import { assert } from 'next/dist/compiled/superstruct'
const validFixtures = [
[
['a', 'b', 'c'],
{
a: [['a', 'b', 'c'], {}],
b: [['a', 'b', 'c'], {}],
},
],
[
['a', 'b', 'c'],
{
a: [['a', 'b', 'c'], {}],
b: [['a', 'b', 'c'], {}],
},
null,
null,
true,
],
[
['a', 'b', 'c'],
{
a: [['a', 'b', 'c'], {}],
b: [['a', 'b', 'c'], {}],
},
null,
'refetch',
],
]
const invalidFixtures = [
// plain wrong
['1', 'b', 'c'],
// invalid enum
[['a', 'b', 'foo'], {}],
// invalid url
[
['a', 'b', 'c'],
{
a: [['a', 'b', 'c'], {}],
b: [['a', 'b', 'c'], {}],
},
{
invalid: 'invalid',
},
],
// invalid isRootLayout
[
['a', 'b', 'c'],
{
a: [['a', 'b', 'c'], {}],
b: [['a', 'b', 'c'], {}],
},
null,
1,
],
]
describe('flightRouterStateSchema', () => {
it('should validate a correct flight router state', () => {
for (const state of validFixtures) {
expect(() => assert(state, flightRouterStateSchema)).not.toThrow()
}
})
it('should not validate an incorrect flight router state', () => {
for (const state of invalidFixtures) {
expect(() => assert(state, flightRouterStateSchema)).toThrow()
}
})
})

View file

@ -5,55 +5,35 @@ import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight
import type { NextFontManifest } from '../../build/webpack/plugins/next-font-manifest-plugin'
import type { ParsedUrlQuery } from 'querystring'
import zod from 'zod'
import s from 'next/dist/compiled/superstruct'
export type DynamicParamTypes = 'catchall' | 'optional-catchall' | 'dynamic'
const dynamicParamTypesSchema = zod.enum(['c', 'oc', 'd'])
/**
* c = catchall
* oc = optional catchall
* d = dynamic
*/
export type DynamicParamTypesShort = zod.infer<typeof dynamicParamTypesSchema>
const dynamicParamTypesSchema = s.enums(['c', 'oc', 'd'])
const segmentSchema = zod.union([
zod.string(),
zod.tuple([zod.string(), zod.string(), dynamicParamTypesSchema]),
export type DynamicParamTypesShort = s.Infer<typeof dynamicParamTypesSchema>
const segmentSchema = s.union([
s.string(),
s.tuple([s.string(), s.string(), dynamicParamTypesSchema]),
])
/**
* Segment in the router state.
*/
export type Segment = zod.infer<typeof segmentSchema>
export const flightRouterStateSchema: zod.ZodType<FlightRouterState> = zod.lazy(
() => {
const parallelRoutesSchema = zod.record(flightRouterStateSchema)
const urlSchema = zod.string().nullable().optional()
const refreshSchema = zod.literal('refetch').nullable().optional()
const isRootLayoutSchema = zod.boolean().optional()
export type Segment = s.Infer<typeof segmentSchema>
// unfortunately the tuple is not understood well by Describe so we have to
// use any here. This does not have any impact on the runtime type since the validation
// does work correctly.
export const flightRouterStateSchema: s.Describe<any> = s.tuple([
segmentSchema,
s.record(
s.string(),
s.lazy(() => flightRouterStateSchema)
),
s.optional(s.nullable(s.string())),
s.optional(s.nullable(s.literal('refetch'))),
s.optional(s.boolean()),
])
// Due to the lack of optional tuple types in Zod, we need to use union here.
// https://github.com/colinhacks/zod/issues/1465
return zod.union([
zod.tuple([
segmentSchema,
parallelRoutesSchema,
urlSchema,
refreshSchema,
isRootLayoutSchema,
]),
zod.tuple([
segmentSchema,
parallelRoutesSchema,
urlSchema,
refreshSchema,
]),
zod.tuple([segmentSchema, parallelRoutesSchema, urlSchema]),
zod.tuple([segmentSchema, parallelRoutesSchema]),
])
}
)
/**
* Router state
*/

View file

@ -1982,6 +1982,16 @@ export async function ncc_unistore(task, opts) {
.ncc({ packageName: 'unistore', externals })
.target('src/compiled/unistore')
}
// eslint-disable-next-line camelcase
externals['unistore'] = 'next/dist/compiled/superstruct'
export async function ncc_superstruct(task, opts) {
await task
.source(relative(__dirname, require.resolve('superstruct')))
.ncc({ packageName: 'superstruct', externals })
.target('src/compiled/superstruct')
}
// eslint-disable-next-line camelcase
externals['web-vitals'] = 'next/dist/compiled/web-vitals'
export async function ncc_web_vitals(task, opts) {
@ -2304,6 +2314,7 @@ export async function ncc(task, opts) {
'ncc_source_map',
'ncc_string_hash',
'ncc_strip_ansi',
'ncc_superstruct',
'ncc_nft',
'ncc_tar',
'ncc_terser',

View file

@ -8,14 +8,38 @@ declare module 'next/dist/compiled/webpack/webpack' {
export let GraphHelpers: any
export let sources: any
export let StringXor: any
export const webpack: any
export const Compiler: any
export const Compilation: any
export const Module: any
export const Stats: any
export const Template: any
export const RuntimeModule: any
export const RuntimeGlobals: any
export const NormalModule: any
export const ResolvePluginInstance: any
namespace webpack {
export type Compiler = any
export type WebpackPluginInstance = any
export type Compilation = any
export type Module = any
export type Stats = any
export type Template = any
export type RuntimeModule = any
export type RuntimeGlobals = any
export type NormalModule = any
export type ResolvePluginInstance = any
export type Configuration = any
export type ResolveOptions = any
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type LoaderContext<T> = any
export type RuleSetUseItem = any
export type EntryObject = any
export type Chunk = any
export type ChunkGroup = any
// eslint-disable-next-line @typescript-eslint/no-shadow
namespace sources {
export type RawSource = any
}
}
export var webpack: any
}
declare module 'next/dist/compiled/superstruct' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type Struct<T, S> = any
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type Infer<T = any> = any
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type Describe<T> = any
}

View file

@ -105,6 +105,11 @@ declare module 'next/dist/compiled/amphtml-validator' {
import m from 'amphtml-validator'
export = m
}
declare module 'next/dist/compiled/superstruct' {
import m from 'superstruct'
export = m
}
declare module 'next/dist/compiled/async-retry'
declare module 'next/dist/compiled/async-sema' {
import m from 'async-sema'

View file

@ -816,9 +816,6 @@ importers:
watchpack:
specifier: 2.4.0
version: 2.4.0
zod:
specifier: 3.21.4
version: 3.21.4
devDependencies:
'@ampproject/toolbox-optimizer':
specifier: 2.8.3
@ -1320,7 +1317,7 @@ importers:
version: 0.13.4
sass-loader:
specifier: 12.4.0
version: 12.4.0(webpack@5.86.0)
version: 12.4.0(sass@1.54.0)(webpack@5.86.0)
schema-utils2:
specifier: npm:schema-utils@2.7.1
version: /schema-utils@2.7.1
@ -1366,6 +1363,9 @@ importers:
strip-ansi:
specifier: 6.0.0
version: 6.0.0
superstruct:
specifier: 1.0.3
version: 1.0.3
tar:
specifier: 6.1.15
version: 6.1.15
@ -23711,7 +23711,7 @@ packages:
/safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
/sass-loader@12.4.0(webpack@5.86.0):
/sass-loader@12.4.0(sass@1.54.0)(webpack@5.86.0):
resolution: {integrity: sha512-7xN+8khDIzym1oL9XyS6zP6Ges+Bo2B2xbPrjdMHEYyV3AQYhd/wXeru++3ODHF0zMjYmVadblSKrPrjEkL8mg==}
engines: {node: '>= 12.13.0'}
peerDependencies:
@ -23729,6 +23729,7 @@ packages:
dependencies:
klona: 2.0.4
neo-async: 2.6.2
sass: 1.54.0
webpack: 5.86.0(@swc/core@1.3.85)
dev: true
@ -24860,6 +24861,11 @@ packages:
- supports-color
dev: true
/superstruct@1.0.3:
resolution: {integrity: sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==}
engines: {node: '>=14.0.0'}
dev: true
/supports-color@2.0.0:
resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}
engines: {node: '>=0.8.0'}

View file

@ -24,7 +24,7 @@
"test/**/*.test.ts",
"test/**/*.test.tsx",
"test/**/test/*",
"packages/next/types/webpack.d.ts",
"packages/next/types/compiled.d.ts",
"test/e2e/pages-dir/client-navigation/index.test.js",
"test/e2e/pages-dir/client-navigation/rendering.js"
]