rsnext/packages/next/next-server/lib/utils.ts
Gerald Monaco 039eb817e1
Remove next-head-count (#16758)
Removes `next-head-count`, improving support for 3rd party libraries that insert or append new elements to `<head>`.

---

This is more or less what a solution with a `data-` attribute would look like, except that instead of directly searching for elements with that attribute, we serialize the elements expected in `<head>` and then find them/assume ownership of them during initialization (in a manner similar to React's reconciliation) based on their properties.

There are two main assumptions here:
1. Content is served with compression, so duplicate serialization of e.g. inline script or style tags doesn't have a meaningful impact. Storing a hash would be a potential optimization.
2. 3rd party libraries primarily only insert new, unique elements to head. Libraries trying to actively manage elements that overlap with those that Next.js claims ownership of will still be unsupported.

The reason for this roundabout approach is that I'd really like to avoid `data-` if possible, for maximum compatibility. Implicitly adding an attribute could be a breaking change for some class of tools or crawlers and makes it otherwise impossible to insert raw HTML into `<head>`. Adding an unexpected attribute is why the original `class="next-head"` approach was problematic in the first place!

That said, while I don't expect this to be more problematic than `next-head-count` (anything that would break in this new model also should have broken in the old model), if that does end up being the case, it might make sense to just bite the bullet.

Fixes #11012
Closes #16707

---

cc @Timer @timneutkens
2020-09-09 01:41:04 +00:00

392 lines
8.8 KiB
TypeScript

import { IncomingMessage, ServerResponse } from 'http'
import { ParsedUrlQuery } from 'querystring'
import { ComponentType } from 'react'
import { UrlObject } from 'url'
import { formatUrl } from './router/utils/format-url'
import { ManifestItem } from '../server/load-components'
import { NextRouter } from './router/router'
import { Env } from '../../lib/load-env-config'
import { BuildManifest } from '../server/get-page-files'
/**
* Types used by both next and next-server
*/
export type NextComponentType<
C extends BaseContext = NextPageContext,
IP = {},
P = {}
> = ComponentType<P> & {
/**
* Used for initial page load data population. Data returned from `getInitialProps` is serialized when server rendered.
* Make sure to return plain `Object` without using `Date`, `Map`, `Set`.
* @param ctx Context of `page`
*/
getInitialProps?(context: C): IP | Promise<IP>
}
export type DocumentType = NextComponentType<
DocumentContext,
DocumentInitialProps,
DocumentProps
> & {
renderDocument(
Document: DocumentType,
props: DocumentProps
): React.ReactElement
}
export type AppType = NextComponentType<
AppContextType,
AppInitialProps,
AppPropsType
>
export type AppTreeType = ComponentType<
AppInitialProps & { [name: string]: any }
>
/**
* Web vitals provided to _app.reportWebVitals by Core Web Vitals plugin developed by Google Chrome team.
* https://nextjs.org/blog/next-9-4#integrated-web-vitals-reporting
*/
export type NextWebVitalsMetric = {
id: string
label: string
name: string
startTime: number
value: number
}
export type Enhancer<C> = (Component: C) => C
export type ComponentsEnhancer =
| {
enhanceApp?: Enhancer<AppType>
enhanceComponent?: Enhancer<NextComponentType>
}
| Enhancer<NextComponentType>
export type RenderPageResult = {
html: string
head?: Array<JSX.Element | null>
}
export type RenderPage = (
options?: ComponentsEnhancer
) => RenderPageResult | Promise<RenderPageResult>
export type BaseContext = {
res?: ServerResponse
[k: string]: any
}
export type HeadEntry = [string, { [key: string]: any }]
export type NEXT_DATA = {
props: Record<string, any>
page: string
query: ParsedUrlQuery
buildId: string
assetPrefix?: string
runtimeConfig?: { [key: string]: any }
nextExport?: boolean
autoExport?: boolean
isFallback?: boolean
dynamicIds?: string[]
err?: Error & { statusCode?: number }
gsp?: boolean
gssp?: boolean
customServer?: boolean
gip?: boolean
appGip?: boolean
head: HeadEntry[]
}
/**
* `Next` context
*/
export interface NextPageContext {
/**
* Error object if encountered during rendering
*/
err?: (Error & { statusCode?: number }) | null
/**
* `HTTP` request object.
*/
req?: IncomingMessage
/**
* `HTTP` response object.
*/
res?: ServerResponse
/**
* Path section of `URL`.
*/
pathname: string
/**
* Query string section of `URL` parsed as an object.
*/
query: ParsedUrlQuery
/**
* `String` of the actual path including query.
*/
asPath?: string
/**
* `Component` the tree of the App to use if needing to render separately
*/
AppTree: AppTreeType
}
export type AppContextType<R extends NextRouter = NextRouter> = {
Component: NextComponentType<NextPageContext>
AppTree: AppTreeType
ctx: NextPageContext
router: R
}
export type AppInitialProps = {
pageProps: any
}
export type AppPropsType<
R extends NextRouter = NextRouter,
P = {}
> = AppInitialProps & {
Component: NextComponentType<NextPageContext, any, P>
router: R
__N_SSG?: boolean
__N_SSP?: boolean
}
export type DocumentContext = NextPageContext & {
renderPage: RenderPage
}
export type DocumentInitialProps = RenderPageResult & {
styles?: React.ReactElement[] | React.ReactFragment
}
export type DocumentProps = DocumentInitialProps & {
__NEXT_DATA__: NEXT_DATA
dangerousAsPath: string
docComponentsRendered: {
Html?: boolean
Main?: boolean
Head?: boolean
NextScript?: boolean
}
buildManifest: BuildManifest
ampPath: string
inAmpMode: boolean
hybridAmp: boolean
isDevelopment: boolean
dynamicImports: ManifestItem[]
assetPrefix?: string
canonicalBase: string
headTags: any[]
unstable_runtimeJS?: false
devOnlyCacheBusterQueryString: string
}
/**
* Next `API` route request
*/
export interface NextApiRequest extends IncomingMessage {
/**
* Object of `query` values from url
*/
query: {
[key: string]: string | string[]
}
/**
* Object of `cookies` from header
*/
cookies: {
[key: string]: string
}
body: any
env: Env
preview?: boolean
/**
* Preview data set on the request, if any
* */
previewData?: any
}
/**
* Send body of response
*/
type Send<T> = (body: T) => void
/**
* Next `API` route response
*/
export type NextApiResponse<T = any> = ServerResponse & {
/**
* Send data `any` data in response
*/
send: Send<T>
/**
* Send data `json` data in response
*/
json: Send<T>
status: (statusCode: number) => NextApiResponse<T>
redirect(url: string): NextApiResponse<T>
redirect(status: number, url: string): NextApiResponse<T>
/**
* Set preview data for Next.js' prerender mode
*/
setPreviewData: (
data: object | string,
options?: {
/**
* Specifies the number (in seconds) for the preview session to last for.
* The given number will be converted to an integer by rounding down.
* By default, no maximum age is set and the preview session finishes
* when the client shuts down (browser is closed).
*/
maxAge?: number
}
) => NextApiResponse<T>
clearPreviewData: () => NextApiResponse<T>
}
/**
* Next `API` route handler
*/
export type NextApiHandler<T = any> = (
req: NextApiRequest,
res: NextApiResponse<T>
) => void | Promise<void>
/**
* Utils
*/
export function execOnce<T extends (...args: any[]) => ReturnType<T>>(
fn: T
): T {
let used = false
let result: ReturnType<T>
return ((...args: any[]) => {
if (!used) {
used = true
result = fn(...args)
}
return result
}) as T
}
export function getLocationOrigin() {
const { protocol, hostname, port } = window.location
return `${protocol}//${hostname}${port ? ':' + port : ''}`
}
export function getURL() {
const { href } = window.location
const origin = getLocationOrigin()
return href.substring(origin.length)
}
export function getDisplayName<P>(Component: ComponentType<P>) {
return typeof Component === 'string'
? Component
: Component.displayName || Component.name || 'Unknown'
}
export function isResSent(res: ServerResponse) {
return res.finished || res.headersSent
}
export async function loadGetInitialProps<
C extends BaseContext,
IP = {},
P = {}
>(App: NextComponentType<C, IP, P>, ctx: C): Promise<IP> {
if (process.env.NODE_ENV !== 'production') {
if (App.prototype?.getInitialProps) {
const message = `"${getDisplayName(
App
)}.getInitialProps()" is defined as an instance method - visit https://err.sh/vercel/next.js/get-initial-props-as-an-instance-method for more information.`
throw new Error(message)
}
}
// when called from _app `ctx` is nested in `ctx`
const res = ctx.res || (ctx.ctx && ctx.ctx.res)
if (!App.getInitialProps) {
if (ctx.ctx && ctx.Component) {
// @ts-ignore pageProps default
return {
pageProps: await loadGetInitialProps(ctx.Component, ctx.ctx),
}
}
return {} as IP
}
const props = await App.getInitialProps(ctx)
if (res && isResSent(res)) {
return props
}
if (!props) {
const message = `"${getDisplayName(
App
)}.getInitialProps()" should resolve to an object. But found "${props}" instead.`
throw new Error(message)
}
if (process.env.NODE_ENV !== 'production') {
if (Object.keys(props).length === 0 && !ctx.ctx) {
console.warn(
`${getDisplayName(
App
)} returned an empty object from \`getInitialProps\`. This de-optimizes and prevents automatic static optimization. https://err.sh/vercel/next.js/empty-object-getInitialProps`
)
}
}
return props
}
export const urlObjectKeys = [
'auth',
'hash',
'host',
'hostname',
'href',
'path',
'pathname',
'port',
'protocol',
'query',
'search',
'slashes',
]
export function formatWithValidation(url: UrlObject): string {
if (process.env.NODE_ENV === 'development') {
if (url !== null && typeof url === 'object') {
Object.keys(url).forEach((key) => {
if (urlObjectKeys.indexOf(key) === -1) {
console.warn(
`Unknown key passed via urlObject into url.format: ${key}`
)
}
})
}
}
return formatUrl(url)
}
export const SP = typeof performance !== 'undefined'
export const ST =
SP &&
typeof performance.mark === 'function' &&
typeof performance.measure === 'function'