Extract anyclip
This commit is contained in:
12
client/add-base-path.ts
Normal file
12
client/add-base-path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { addPathPrefix } from '../shared/lib/router/utils/add-path-prefix'
|
||||
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||
|
||||
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||
|
||||
export function addBasePath(path: string, required?: boolean): string {
|
||||
return normalizePathTrailingSlash(
|
||||
process.env.__NEXT_MANUAL_CLIENT_BASE_PATH && !required
|
||||
? path
|
||||
: addPathPrefix(path, basePath)
|
||||
)
|
||||
}
|
||||
13
client/add-locale.ts
Normal file
13
client/add-locale.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { addLocale as Fn } from '../shared/lib/router/utils/add-locale'
|
||||
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||
|
||||
export const addLocale: typeof Fn = (path, ...args) => {
|
||||
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||
return normalizePathTrailingSlash(
|
||||
(
|
||||
require('../shared/lib/router/utils/add-locale') as typeof import('../shared/lib/router/utils/add-locale')
|
||||
).addLocale(path, ...args)
|
||||
)
|
||||
}
|
||||
return path
|
||||
}
|
||||
9
client/detect-domain-locale.ts
Normal file
9
client/detect-domain-locale.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { detectDomainLocale as Fn } from '../shared/lib/i18n/detect-domain-locale'
|
||||
|
||||
export const detectDomainLocale: typeof Fn = (...args) => {
|
||||
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||
return (
|
||||
require('../shared/lib/i18n/detect-domain-locale') as typeof import('../shared/lib/i18n/detect-domain-locale')
|
||||
).detectDomainLocale(...args)
|
||||
}
|
||||
}
|
||||
7
client/has-base-path.ts
Normal file
7
client/has-base-path.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { pathHasPrefix } from '../shared/lib/router/utils/path-has-prefix'
|
||||
|
||||
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||
|
||||
export function hasBasePath(path: string): boolean {
|
||||
return pathHasPrefix(path, basePath)
|
||||
}
|
||||
150
client/head-manager.ts
Normal file
150
client/head-manager.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { setAttributesFromProps } from './set-attributes-from-props'
|
||||
|
||||
import type { JSX } from 'react'
|
||||
|
||||
function reactElementToDOM({ type, props }: JSX.Element): HTMLElement {
|
||||
const el: HTMLElement = document.createElement(type)
|
||||
setAttributesFromProps(el, props)
|
||||
|
||||
const { children, dangerouslySetInnerHTML } = props
|
||||
if (dangerouslySetInnerHTML) {
|
||||
el.innerHTML = dangerouslySetInnerHTML.__html || ''
|
||||
} else if (children) {
|
||||
el.textContent =
|
||||
typeof children === 'string'
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join('')
|
||||
: ''
|
||||
}
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* When a `nonce` is present on an element, browsers such as Chrome and Firefox strip it out of the
|
||||
* actual HTML attributes for security reasons *when the element is added to the document*. Thus,
|
||||
* given two equivalent elements that have nonces, `Element,isEqualNode()` will return false if one
|
||||
* of those elements gets added to the document. Although the `element.nonce` property will be the
|
||||
* same for both elements, the one that was added to the document will return an empty string for
|
||||
* its nonce HTML attribute value.
|
||||
*
|
||||
* This custom `isEqualNode()` function therefore removes the nonce value from the `newTag` before
|
||||
* comparing it to `oldTag`, restoring it afterwards.
|
||||
*
|
||||
* For more information, see:
|
||||
* https://bugs.chromium.org/p/chromium/issues/detail?id=1211471#c12
|
||||
*/
|
||||
export function isEqualNode(oldTag: Element, newTag: Element) {
|
||||
if (oldTag instanceof HTMLElement && newTag instanceof HTMLElement) {
|
||||
const nonce = newTag.getAttribute('nonce')
|
||||
// Only strip the nonce if `oldTag` has had it stripped. An element's nonce attribute will not
|
||||
// be stripped if there is no content security policy response header that includes a nonce.
|
||||
if (nonce && !oldTag.getAttribute('nonce')) {
|
||||
const cloneTag = newTag.cloneNode(true) as typeof newTag
|
||||
cloneTag.setAttribute('nonce', '')
|
||||
cloneTag.nonce = nonce
|
||||
return nonce === oldTag.nonce && oldTag.isEqualNode(cloneTag)
|
||||
}
|
||||
}
|
||||
|
||||
return oldTag.isEqualNode(newTag)
|
||||
}
|
||||
|
||||
function updateElements(type: string, components: JSX.Element[]) {
|
||||
const headEl = document.querySelector('head')
|
||||
if (!headEl) return
|
||||
|
||||
const oldTags = new Set(headEl.querySelectorAll(`${type}[data-next-head]`))
|
||||
|
||||
if (type === 'meta') {
|
||||
const metaCharset = headEl.querySelector('meta[charset]')
|
||||
if (metaCharset !== null) {
|
||||
oldTags.add(metaCharset)
|
||||
}
|
||||
}
|
||||
|
||||
const newTags: Element[] = []
|
||||
for (let i = 0; i < components.length; i++) {
|
||||
const component = components[i]
|
||||
const newTag = reactElementToDOM(component)
|
||||
newTag.setAttribute('data-next-head', '')
|
||||
|
||||
let isNew = true
|
||||
for (const oldTag of oldTags) {
|
||||
if (isEqualNode(oldTag, newTag)) {
|
||||
oldTags.delete(oldTag)
|
||||
isNew = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
newTags.push(newTag)
|
||||
}
|
||||
}
|
||||
|
||||
for (const oldTag of oldTags) {
|
||||
oldTag.parentNode?.removeChild(oldTag)
|
||||
}
|
||||
|
||||
for (const newTag of newTags) {
|
||||
// meta[charset] must be first element so special case
|
||||
if (
|
||||
newTag.tagName.toLowerCase() === 'meta' &&
|
||||
newTag.getAttribute('charset') !== null
|
||||
) {
|
||||
headEl.prepend(newTag)
|
||||
}
|
||||
headEl.appendChild(newTag)
|
||||
}
|
||||
}
|
||||
|
||||
export default function initHeadManager(): {
|
||||
mountedInstances: Set<unknown>
|
||||
updateHead: (head: JSX.Element[]) => void
|
||||
} {
|
||||
return {
|
||||
mountedInstances: new Set(),
|
||||
updateHead: (head: JSX.Element[]) => {
|
||||
const tags: Record<string, JSX.Element[]> = {}
|
||||
|
||||
head.forEach((h) => {
|
||||
if (
|
||||
// If the font tag is loaded only on client navigation
|
||||
// it won't be inlined. In this case revert to the original behavior
|
||||
h.type === 'link' &&
|
||||
h.props['data-optimized-fonts']
|
||||
) {
|
||||
if (
|
||||
document.querySelector(`style[data-href="${h.props['data-href']}"]`)
|
||||
) {
|
||||
return
|
||||
} else {
|
||||
h.props.href = h.props['data-href']
|
||||
h.props['data-href'] = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const components = tags[h.type] || []
|
||||
components.push(h)
|
||||
tags[h.type] = components
|
||||
})
|
||||
|
||||
const titleComponent = tags.title ? tags.title[0] : null
|
||||
let title = ''
|
||||
if (titleComponent) {
|
||||
const { children } = titleComponent.props
|
||||
title =
|
||||
typeof children === 'string'
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join('')
|
||||
: ''
|
||||
}
|
||||
if (title !== document.title) document.title = title
|
||||
;['meta', 'base', 'link', 'style', 'script'].forEach((type) => {
|
||||
updateElements(type, tags[type] || [])
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
1008
client/index.tsx
Normal file
1008
client/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
client/next.ts
Normal file
23
client/next.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import './webpack'
|
||||
import '../lib/require-instrumentation-client'
|
||||
|
||||
import { initialize, hydrate, version, router, emitter } from './'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
next: any
|
||||
}
|
||||
}
|
||||
|
||||
window.next = {
|
||||
version,
|
||||
// router is initialized later so it has to be live-binded
|
||||
get router() {
|
||||
return router
|
||||
},
|
||||
emitter,
|
||||
}
|
||||
|
||||
initialize({})
|
||||
.then(() => hydrate())
|
||||
.catch(console.error)
|
||||
25
client/normalize-trailing-slash.ts
Normal file
25
client/normalize-trailing-slash.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
|
||||
import { parsePath } from '../shared/lib/router/utils/parse-path'
|
||||
|
||||
/**
|
||||
* Normalizes the trailing slash of a path according to the `trailingSlash` option
|
||||
* in `next.config.js`.
|
||||
*/
|
||||
export const normalizePathTrailingSlash = (path: string) => {
|
||||
if (!path.startsWith('/') || process.env.__NEXT_MANUAL_TRAILING_SLASH) {
|
||||
return path
|
||||
}
|
||||
|
||||
const { pathname, query, hash } = parsePath(path)
|
||||
if (process.env.__NEXT_TRAILING_SLASH) {
|
||||
if (/\.[^/]+\/?$/.test(pathname)) {
|
||||
return `${removeTrailingSlash(pathname)}${query}${hash}`
|
||||
} else if (pathname.endsWith('/')) {
|
||||
return `${pathname}${query}${hash}`
|
||||
} else {
|
||||
return `${pathname}/${query}${hash}`
|
||||
}
|
||||
}
|
||||
|
||||
return `${removeTrailingSlash(pathname)}${query}${hash}`
|
||||
}
|
||||
210
client/page-loader.ts
Normal file
210
client/page-loader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { RouteLoader } from './route-loader'
|
||||
import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info'
|
||||
import { addBasePath } from './add-base-path'
|
||||
import { interpolateAs } from '../shared/lib/router/utils/interpolate-as'
|
||||
import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route'
|
||||
import { addLocale } from './add-locale'
|
||||
import { isDynamicRoute } from '../shared/lib/router/utils/is-dynamic'
|
||||
import { parseRelativeUrl } from '../shared/lib/router/utils/parse-relative-url'
|
||||
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
|
||||
import { createRouteLoader, getClientBuildManifest } from './route-loader'
|
||||
import {
|
||||
DEV_CLIENT_PAGES_MANIFEST,
|
||||
DEV_CLIENT_MIDDLEWARE_MANIFEST,
|
||||
TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST,
|
||||
} from '../shared/lib/constants'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__DEV_MIDDLEWARE_MATCHERS?: MiddlewareMatcher[]
|
||||
__DEV_PAGES_MANIFEST?: { pages: string[] }
|
||||
__SSG_MANIFEST_CB?: () => void
|
||||
__SSG_MANIFEST?: Set<string>
|
||||
}
|
||||
}
|
||||
|
||||
export type StyleSheetTuple = { href: string; text: string }
|
||||
export type GoodPageCache = {
|
||||
page: ComponentType
|
||||
mod: any
|
||||
styleSheets: StyleSheetTuple[]
|
||||
}
|
||||
|
||||
export default class PageLoader {
|
||||
private buildId: string
|
||||
private assetPrefix: string
|
||||
private promisedSsgManifest: Promise<Set<string>>
|
||||
private promisedDevPagesManifest?: Promise<string[]>
|
||||
private promisedMiddlewareMatchers?: Promise<MiddlewareMatcher[]>
|
||||
|
||||
public routeLoader: RouteLoader
|
||||
|
||||
constructor(buildId: string, assetPrefix: string) {
|
||||
this.routeLoader = createRouteLoader(assetPrefix)
|
||||
|
||||
this.buildId = buildId
|
||||
this.assetPrefix = assetPrefix
|
||||
|
||||
this.promisedSsgManifest = new Promise((resolve) => {
|
||||
if (window.__SSG_MANIFEST) {
|
||||
resolve(window.__SSG_MANIFEST)
|
||||
} else {
|
||||
window.__SSG_MANIFEST_CB = () => {
|
||||
resolve(window.__SSG_MANIFEST!)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getPageList() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return getClientBuildManifest().then((manifest) => manifest.sortedPages)
|
||||
} else {
|
||||
if (window.__DEV_PAGES_MANIFEST) {
|
||||
return window.__DEV_PAGES_MANIFEST.pages
|
||||
} else {
|
||||
this.promisedDevPagesManifest ||= fetch(
|
||||
`${this.assetPrefix}/_next/static/development/${DEV_CLIENT_PAGES_MANIFEST}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((manifest: { pages: string[] }) => {
|
||||
window.__DEV_PAGES_MANIFEST = manifest
|
||||
return manifest.pages
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`Failed to fetch devPagesManifest:`, err)
|
||||
throw new Error(
|
||||
`Failed to fetch _devPagesManifest.json. Is something blocking that network request?\n` +
|
||||
'Read more: https://nextjs.org/docs/messages/failed-to-fetch-devpagesmanifest'
|
||||
)
|
||||
})
|
||||
return this.promisedDevPagesManifest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMiddleware() {
|
||||
// Webpack production
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
process.env.__NEXT_MIDDLEWARE_MATCHERS
|
||||
) {
|
||||
const middlewareMatchers = process.env.__NEXT_MIDDLEWARE_MATCHERS
|
||||
window.__MIDDLEWARE_MATCHERS = middlewareMatchers
|
||||
? (middlewareMatchers as any as MiddlewareMatcher[])
|
||||
: undefined
|
||||
return window.__MIDDLEWARE_MATCHERS
|
||||
// Turbopack production
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
if (window.__MIDDLEWARE_MATCHERS) {
|
||||
return window.__MIDDLEWARE_MATCHERS
|
||||
} else {
|
||||
if (!this.promisedMiddlewareMatchers) {
|
||||
// TODO: Decide what should happen when fetching fails instead of asserting
|
||||
// @ts-ignore
|
||||
this.promisedMiddlewareMatchers = fetch(
|
||||
`${this.assetPrefix}/_next/static/${this.buildId}/${TURBOPACK_CLIENT_MIDDLEWARE_MANIFEST}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((matchers: MiddlewareMatcher[]) => {
|
||||
window.__MIDDLEWARE_MATCHERS = matchers
|
||||
return matchers
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`Failed to fetch _devMiddlewareManifest`, err)
|
||||
})
|
||||
}
|
||||
// TODO Remove this assertion as this could be undefined
|
||||
return this.promisedMiddlewareMatchers!
|
||||
}
|
||||
// Development both Turbopack and Webpack
|
||||
} else {
|
||||
if (window.__DEV_MIDDLEWARE_MATCHERS) {
|
||||
return window.__DEV_MIDDLEWARE_MATCHERS
|
||||
} else {
|
||||
if (!this.promisedMiddlewareMatchers) {
|
||||
// TODO: Decide what should happen when fetching fails instead of asserting
|
||||
// @ts-ignore
|
||||
this.promisedMiddlewareMatchers = fetch(
|
||||
`${this.assetPrefix}/_next/static/${this.buildId}/${DEV_CLIENT_MIDDLEWARE_MANIFEST}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((matchers: MiddlewareMatcher[]) => {
|
||||
window.__DEV_MIDDLEWARE_MATCHERS = matchers
|
||||
return matchers
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`Failed to fetch _devMiddlewareManifest`, err)
|
||||
})
|
||||
}
|
||||
// TODO Remove this assertion as this could be undefined
|
||||
return this.promisedMiddlewareMatchers!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDataHref(params: {
|
||||
asPath: string
|
||||
href: string
|
||||
locale?: string | false
|
||||
skipInterpolation?: boolean
|
||||
}): string {
|
||||
const { asPath, href, locale } = params
|
||||
const { pathname: hrefPathname, query, search } = parseRelativeUrl(href)
|
||||
const { pathname: asPathname } = parseRelativeUrl(asPath)
|
||||
const route = removeTrailingSlash(hrefPathname)
|
||||
if (route[0] !== '/') {
|
||||
throw new Error(`Route name should start with a "/", got "${route}"`)
|
||||
}
|
||||
|
||||
const getHrefForSlug = (path: string) => {
|
||||
const dataRoute = getAssetPathFromRoute(
|
||||
removeTrailingSlash(addLocale(path, locale)),
|
||||
'.json'
|
||||
)
|
||||
return addBasePath(
|
||||
`/_next/data/${this.buildId}${dataRoute}${search}`,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
return getHrefForSlug(
|
||||
params.skipInterpolation
|
||||
? asPathname
|
||||
: isDynamicRoute(route)
|
||||
? interpolateAs(hrefPathname, asPathname, query).result
|
||||
: route
|
||||
)
|
||||
}
|
||||
|
||||
_isSsg(
|
||||
/** the route (file-system path) */
|
||||
route: string
|
||||
): Promise<boolean> {
|
||||
return this.promisedSsgManifest.then((manifest) => manifest.has(route))
|
||||
}
|
||||
|
||||
loadPage(route: string): Promise<GoodPageCache> {
|
||||
return this.routeLoader.loadRoute(route).then((res) => {
|
||||
if ('component' in res) {
|
||||
return {
|
||||
page: res.component,
|
||||
mod: res.exports,
|
||||
styleSheets: res.styles.map((o) => ({
|
||||
href: o.href,
|
||||
text: o.content,
|
||||
})),
|
||||
}
|
||||
}
|
||||
throw res.error
|
||||
})
|
||||
}
|
||||
|
||||
prefetch(route: string): Promise<void> {
|
||||
return this.routeLoader.prefetch(route)
|
||||
}
|
||||
}
|
||||
18
client/remove-base-path.ts
Normal file
18
client/remove-base-path.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { hasBasePath } from './has-base-path'
|
||||
|
||||
const basePath = (process.env.__NEXT_ROUTER_BASEPATH as string) || ''
|
||||
|
||||
export function removeBasePath(path: string): string {
|
||||
if (process.env.__NEXT_MANUAL_CLIENT_BASE_PATH) {
|
||||
if (!hasBasePath(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Can't trim the basePath if it has zero length!
|
||||
if (basePath.length === 0) return path
|
||||
|
||||
path = path.slice(basePath.length)
|
||||
if (!path.startsWith('/')) path = `/${path}`
|
||||
return path
|
||||
}
|
||||
18
client/remove-locale.ts
Normal file
18
client/remove-locale.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { parsePath } from '../shared/lib/router/utils/parse-path'
|
||||
|
||||
export function removeLocale(path: string, locale?: string) {
|
||||
if (process.env.__NEXT_I18N_SUPPORT) {
|
||||
const { pathname } = parsePath(path)
|
||||
const pathLower = pathname.toLowerCase()
|
||||
const localeLower = locale?.toLowerCase()
|
||||
|
||||
return locale &&
|
||||
(pathLower.startsWith(`/${localeLower}/`) ||
|
||||
pathLower === `/${localeLower}`)
|
||||
? `${pathname.length === locale.length + 1 ? `/` : ``}${path.slice(
|
||||
locale.length + 1
|
||||
)}`
|
||||
: path
|
||||
}
|
||||
return path
|
||||
}
|
||||
23
client/request-idle-callback.ts
Normal file
23
client/request-idle-callback.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const requestIdleCallback =
|
||||
(typeof self !== 'undefined' &&
|
||||
self.requestIdleCallback &&
|
||||
self.requestIdleCallback.bind(window)) ||
|
||||
function (cb: IdleRequestCallback): number {
|
||||
let start = Date.now()
|
||||
return self.setTimeout(function () {
|
||||
cb({
|
||||
didTimeout: false,
|
||||
timeRemaining: function () {
|
||||
return Math.max(0, 50 - (Date.now() - start))
|
||||
},
|
||||
})
|
||||
}, 1)
|
||||
}
|
||||
|
||||
export const cancelIdleCallback =
|
||||
(typeof self !== 'undefined' &&
|
||||
self.cancelIdleCallback &&
|
||||
self.cancelIdleCallback.bind(window)) ||
|
||||
function (id: number) {
|
||||
return clearTimeout(id)
|
||||
}
|
||||
138
client/resolve-href.ts
Normal file
138
client/resolve-href.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { NextRouter, Url } from '../shared/lib/router/router'
|
||||
|
||||
import { searchParamsToUrlQuery } from '../shared/lib/router/utils/querystring'
|
||||
import { formatWithValidation } from '../shared/lib/router/utils/format-url'
|
||||
import { omit } from '../shared/lib/router/utils/omit'
|
||||
import { normalizeRepeatedSlashes } from '../shared/lib/utils'
|
||||
import { normalizePathTrailingSlash } from './normalize-trailing-slash'
|
||||
import { isLocalURL } from '../shared/lib/router/utils/is-local-url'
|
||||
import { isDynamicRoute } from '../shared/lib/router/utils'
|
||||
import { interpolateAs } from '../shared/lib/router/utils/interpolate-as'
|
||||
import { getRouteRegex } from '../shared/lib/router/utils/route-regex'
|
||||
import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher'
|
||||
|
||||
/**
|
||||
* Resolves a given hyperlink with a certain router state (basePath not included).
|
||||
* Preserves absolute urls.
|
||||
*/
|
||||
export function resolveHref(
|
||||
router: NextRouter,
|
||||
href: Url,
|
||||
resolveAs: true
|
||||
): [string, string] | [string]
|
||||
export function resolveHref(
|
||||
router: NextRouter,
|
||||
href: Url,
|
||||
resolveAs?: false
|
||||
): string
|
||||
export function resolveHref(
|
||||
router: NextRouter,
|
||||
href: Url,
|
||||
resolveAs?: boolean
|
||||
): [string, string] | [string] | string {
|
||||
// we use a dummy base url for relative urls
|
||||
let base: URL
|
||||
let urlAsString = typeof href === 'string' ? href : formatWithValidation(href)
|
||||
|
||||
// repeated slashes and backslashes in the URL are considered
|
||||
// invalid and will never match a Next.js page/file
|
||||
// https://www.rfc-editor.org/rfc/rfc3986.html#section-3.1
|
||||
const urlProtoMatch = urlAsString.match(/^[a-z][a-z0-9+.-]*:\/\//i)
|
||||
const urlAsStringNoProto = urlProtoMatch
|
||||
? urlAsString.slice(urlProtoMatch[0].length)
|
||||
: urlAsString
|
||||
|
||||
const urlParts = urlAsStringNoProto.split('?', 1)
|
||||
|
||||
if ((urlParts[0] || '').match(/(\/\/|\\)/)) {
|
||||
console.error(
|
||||
`Invalid href '${urlAsString}' passed to next/router in page: '${router.pathname}'. Repeated forward-slashes (//) or backslashes \\ are not valid in the href.`
|
||||
)
|
||||
const normalizedUrl = normalizeRepeatedSlashes(urlAsStringNoProto)
|
||||
urlAsString = (urlProtoMatch ? urlProtoMatch[0] : '') + normalizedUrl
|
||||
}
|
||||
|
||||
// Return because it cannot be routed by the Next.js router
|
||||
if (!isLocalURL(urlAsString)) {
|
||||
return (resolveAs ? [urlAsString] : urlAsString) as string
|
||||
}
|
||||
|
||||
try {
|
||||
let baseBase = urlAsString.startsWith('#') ? router.asPath : router.pathname
|
||||
|
||||
// If the provided href is only a query string, it is safer to use the asPath
|
||||
// considering rewrites.
|
||||
if (urlAsString.startsWith('?')) {
|
||||
baseBase = router.asPath
|
||||
|
||||
// However, if is a dynamic route, we need to use the pathname to preserve the
|
||||
// query interpolation and rewrites (router.pathname will look like "/[slug]").
|
||||
if (isDynamicRoute(router.pathname)) {
|
||||
baseBase = router.pathname
|
||||
|
||||
const routeRegex = getRouteRegex(router.pathname)
|
||||
const match = getRouteMatcher(routeRegex)(router.asPath)
|
||||
|
||||
// For dynamic routes, if asPath doesn't match the pathname regex, it is a rewritten path.
|
||||
// In this case, should use asPath to preserve the current URL.
|
||||
if (!match) {
|
||||
baseBase = router.asPath
|
||||
}
|
||||
|
||||
// Note: There is an edge case where the pathname is dynamic, and also a rewrite path to the same segment.
|
||||
// E.g. in "/[slug]" path, rewrite "/foo" -> "/bar"
|
||||
|
||||
// In this case, it will be treated as a non-rewritten path and possibly interpolate the query string.
|
||||
// E.g., "/any?slug=foo" will become the content of "/foo", not rewritten as "/bar"
|
||||
|
||||
// This is currently a trade-off of not resolving rewrite paths on every Router/Link call,
|
||||
// but using a lighter route regex pattern check.
|
||||
}
|
||||
}
|
||||
|
||||
base = new URL(baseBase, 'http://n')
|
||||
} catch (_) {
|
||||
// fallback to / for invalid asPath values e.g. //
|
||||
base = new URL('/', 'http://n')
|
||||
}
|
||||
|
||||
try {
|
||||
const finalUrl = new URL(urlAsString, base)
|
||||
finalUrl.pathname = normalizePathTrailingSlash(finalUrl.pathname)
|
||||
let interpolatedAs = ''
|
||||
|
||||
if (
|
||||
isDynamicRoute(finalUrl.pathname) &&
|
||||
finalUrl.searchParams &&
|
||||
resolveAs
|
||||
) {
|
||||
const query = searchParamsToUrlQuery(finalUrl.searchParams)
|
||||
|
||||
const { result, params } = interpolateAs(
|
||||
finalUrl.pathname,
|
||||
finalUrl.pathname,
|
||||
query
|
||||
)
|
||||
|
||||
if (result) {
|
||||
interpolatedAs = formatWithValidation({
|
||||
pathname: result,
|
||||
hash: finalUrl.hash,
|
||||
query: omit(query, params),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// if the origin didn't change, it means we received a relative href
|
||||
const resolvedHref =
|
||||
finalUrl.origin === base.origin
|
||||
? finalUrl.href.slice(finalUrl.origin.length)
|
||||
: finalUrl.href
|
||||
|
||||
return resolveAs
|
||||
? [resolvedHref, interpolatedAs || resolvedHref]
|
||||
: resolvedHref
|
||||
} catch (_) {
|
||||
return resolveAs ? [urlAsString] : urlAsString
|
||||
}
|
||||
}
|
||||
65
client/route-announcer.tsx
Normal file
65
client/route-announcer.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { useRouter } from './router'
|
||||
|
||||
const nextjsRouteAnnouncerStyles: React.CSSProperties = {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
height: '1px',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '1px',
|
||||
|
||||
// https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
|
||||
whiteSpace: 'nowrap',
|
||||
wordWrap: 'normal',
|
||||
}
|
||||
|
||||
export const RouteAnnouncer = () => {
|
||||
const { asPath } = useRouter()
|
||||
const [routeAnnouncement, setRouteAnnouncement] = React.useState('')
|
||||
|
||||
// Only announce the path change, but not for the first load because screen
|
||||
// reader will do that automatically.
|
||||
const previouslyLoadedPath = React.useRef(asPath)
|
||||
|
||||
// Every time the path changes, announce the new page’s title following this
|
||||
// priority: first the document title (from head), otherwise the first h1, or
|
||||
// if none of these exist, then the pathname from the URL. This methodology is
|
||||
// inspired by Marcy Sutton’s accessible client routing user testing. More
|
||||
// information can be found here:
|
||||
// https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
|
||||
React.useEffect(
|
||||
() => {
|
||||
// If the path hasn't change, we do nothing.
|
||||
if (previouslyLoadedPath.current === asPath) return
|
||||
previouslyLoadedPath.current = asPath
|
||||
|
||||
if (document.title) {
|
||||
setRouteAnnouncement(document.title)
|
||||
} else {
|
||||
const pageHeader = document.querySelector('h1')
|
||||
const content = pageHeader?.innerText ?? pageHeader?.textContent
|
||||
|
||||
setRouteAnnouncement(content || asPath)
|
||||
}
|
||||
},
|
||||
// TODO: switch to pathname + query object of dynamic route requirements
|
||||
[asPath]
|
||||
)
|
||||
|
||||
return (
|
||||
<p
|
||||
aria-live="assertive" // Make the announcement immediately.
|
||||
id="__next-route-announcer__"
|
||||
role="alert"
|
||||
style={nextjsRouteAnnouncerStyles}
|
||||
>
|
||||
{routeAnnouncement}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteAnnouncer
|
||||
453
client/route-loader.ts
Normal file
453
client/route-loader.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { MiddlewareMatcher } from '../build/analysis/get-page-static-info'
|
||||
import getAssetPathFromRoute from '../shared/lib/router/utils/get-asset-path-from-route'
|
||||
import { __unsafeCreateTrustedScriptURL } from './trusted-types'
|
||||
import { requestIdleCallback } from './request-idle-callback'
|
||||
import { getDeploymentIdQueryOrEmptyString } from '../build/deployment-id'
|
||||
import { encodeURIPath } from '../shared/lib/encode-uri-path'
|
||||
|
||||
// 3.8s was arbitrarily chosen as it's what https://web.dev/interactive
|
||||
// considers as "Good" time-to-interactive. We must assume something went
|
||||
// wrong beyond this point, and then fall-back to a full page transition to
|
||||
// show the user something of value.
|
||||
const MS_MAX_IDLE_DELAY = 3800
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__BUILD_MANIFEST?: Record<string, string[]>
|
||||
__BUILD_MANIFEST_CB?: Function
|
||||
__MIDDLEWARE_MATCHERS?: MiddlewareMatcher[]
|
||||
__MIDDLEWARE_MANIFEST_CB?: Function
|
||||
__REACT_LOADABLE_MANIFEST?: any
|
||||
__DYNAMIC_CSS_MANIFEST?: any
|
||||
__RSC_MANIFEST?: any
|
||||
__RSC_SERVER_MANIFEST?: any
|
||||
__NEXT_FONT_MANIFEST?: any
|
||||
__SUBRESOURCE_INTEGRITY_MANIFEST?: string
|
||||
__INTERCEPTION_ROUTE_REWRITE_MANIFEST?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadedEntrypointSuccess {
|
||||
component: ComponentType
|
||||
exports: any
|
||||
}
|
||||
interface LoadedEntrypointFailure {
|
||||
error: unknown
|
||||
}
|
||||
type RouteEntrypoint = LoadedEntrypointSuccess | LoadedEntrypointFailure
|
||||
|
||||
interface RouteStyleSheet {
|
||||
href: string
|
||||
content: string
|
||||
}
|
||||
|
||||
interface LoadedRouteSuccess extends LoadedEntrypointSuccess {
|
||||
styles: RouteStyleSheet[]
|
||||
}
|
||||
interface LoadedRouteFailure {
|
||||
error: unknown
|
||||
}
|
||||
type RouteLoaderEntry = LoadedRouteSuccess | LoadedRouteFailure
|
||||
|
||||
interface Future<V> {
|
||||
resolve: (entrypoint: V) => void
|
||||
future: Promise<V>
|
||||
}
|
||||
function withFuture<T extends object>(
|
||||
key: string,
|
||||
map: Map<string, Future<T> | T>,
|
||||
generator?: () => Promise<T>
|
||||
): Promise<T> {
|
||||
let entry = map.get(key)
|
||||
if (entry) {
|
||||
if ('future' in entry) {
|
||||
return entry.future
|
||||
}
|
||||
return Promise.resolve(entry)
|
||||
}
|
||||
let resolver: (entrypoint: T) => void
|
||||
const prom: Promise<T> = new Promise<T>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
map.set(key, { resolve: resolver!, future: prom })
|
||||
return generator
|
||||
? generator()
|
||||
.then((value) => {
|
||||
resolver(value)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
map.delete(key)
|
||||
throw err
|
||||
})
|
||||
: prom
|
||||
}
|
||||
|
||||
export interface RouteLoader {
|
||||
whenEntrypoint(route: string): Promise<RouteEntrypoint>
|
||||
onEntrypoint(route: string, execute: () => unknown): void
|
||||
loadRoute(route: string, prefetch?: boolean): Promise<RouteLoaderEntry>
|
||||
prefetch(route: string): Promise<void>
|
||||
}
|
||||
|
||||
const ASSET_LOAD_ERROR = Symbol('ASSET_LOAD_ERROR')
|
||||
// TODO: unexport
|
||||
export function markAssetError(err: Error): Error {
|
||||
return Object.defineProperty(err, ASSET_LOAD_ERROR, {})
|
||||
}
|
||||
|
||||
export function isAssetError(err?: Error): boolean | undefined {
|
||||
return err && ASSET_LOAD_ERROR in err
|
||||
}
|
||||
|
||||
function hasPrefetch(link?: HTMLLinkElement): boolean {
|
||||
try {
|
||||
link = document.createElement('link')
|
||||
return (
|
||||
// detect IE11 since it supports prefetch but isn't detected
|
||||
// with relList.support
|
||||
(!!window.MSInputMethodContext && !!(document as any).documentMode) ||
|
||||
link.relList.supports('prefetch')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const canPrefetch: boolean = hasPrefetch()
|
||||
|
||||
const getAssetQueryString = () => {
|
||||
return getDeploymentIdQueryOrEmptyString()
|
||||
}
|
||||
|
||||
function prefetchViaDom(
|
||||
href: string,
|
||||
as: string,
|
||||
link?: HTMLLinkElement
|
||||
): Promise<any> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const selector = `
|
||||
link[rel="prefetch"][href^="${href}"],
|
||||
link[rel="preload"][href^="${href}"],
|
||||
script[src^="${href}"]`
|
||||
if (document.querySelector(selector)) {
|
||||
return resolve()
|
||||
}
|
||||
|
||||
link = document.createElement('link')
|
||||
|
||||
// The order of property assignment here is intentional:
|
||||
if (as) link!.as = as
|
||||
link!.rel = `prefetch`
|
||||
link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
|
||||
link!.onload = resolve as any
|
||||
link!.onerror = () =>
|
||||
reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))
|
||||
|
||||
// `href` should always be last:
|
||||
link!.href = href
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
function appendScript(
|
||||
src: TrustedScriptURL | string,
|
||||
script?: HTMLScriptElement
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
script = document.createElement('script')
|
||||
|
||||
// The order of property assignment here is intentional.
|
||||
// 1. Setup success/failure hooks in case the browser synchronously
|
||||
// executes when `src` is set.
|
||||
script.onload = resolve
|
||||
script.onerror = () =>
|
||||
reject(markAssetError(new Error(`Failed to load script: ${src}`)))
|
||||
|
||||
// 2. Configure the cross-origin attribute before setting `src` in case the
|
||||
// browser begins to fetch.
|
||||
script.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
|
||||
|
||||
// 3. Finally, set the source and inject into the DOM in case the child
|
||||
// must be appended for fetching to start.
|
||||
script.src = src as string
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// We wait for pages to be built in dev before we start the route transition
|
||||
// timeout to prevent an un-necessary hard navigation in development.
|
||||
let devBuildPromise: Promise<void> | undefined
|
||||
|
||||
// Resolve a promise that times out after given amount of milliseconds.
|
||||
function resolvePromiseWithTimeout<T>(
|
||||
p: Promise<T>,
|
||||
ms: number,
|
||||
err: Error
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cancelled = false
|
||||
|
||||
p.then((r) => {
|
||||
// Resolved, cancel the timeout
|
||||
cancelled = true
|
||||
resolve(r)
|
||||
}).catch(reject)
|
||||
|
||||
// We wrap these checks separately for better dead-code elimination in
|
||||
// production bundles.
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
;(devBuildPromise || Promise.resolve()).then(() => {
|
||||
requestIdleCallback(() =>
|
||||
setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
reject(err)
|
||||
}
|
||||
}, ms)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
requestIdleCallback(() =>
|
||||
setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
reject(err)
|
||||
}
|
||||
}, ms)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: stop exporting or cache the failure
|
||||
// It'd be best to stop exporting this. It's an implementation detail. We're
|
||||
// only exporting it for backwards compatibility with the `page-loader`.
|
||||
// Only cache this response as a last resort if we cannot eliminate all other
|
||||
// code branches that use the Build Manifest Callback and push them through
|
||||
// the Route Loader interface.
|
||||
export function getClientBuildManifest() {
|
||||
if (self.__BUILD_MANIFEST) {
|
||||
return Promise.resolve(self.__BUILD_MANIFEST)
|
||||
}
|
||||
|
||||
const onBuildManifest = new Promise<Record<string, string[]>>((resolve) => {
|
||||
// Mandatory because this is not concurrent safe:
|
||||
const cb = self.__BUILD_MANIFEST_CB
|
||||
self.__BUILD_MANIFEST_CB = () => {
|
||||
resolve(self.__BUILD_MANIFEST!)
|
||||
cb && cb()
|
||||
}
|
||||
})
|
||||
|
||||
return resolvePromiseWithTimeout(
|
||||
onBuildManifest,
|
||||
MS_MAX_IDLE_DELAY,
|
||||
markAssetError(new Error('Failed to load client build manifest'))
|
||||
)
|
||||
}
|
||||
|
||||
interface RouteFiles {
|
||||
scripts: (TrustedScriptURL | string)[]
|
||||
css: string[]
|
||||
}
|
||||
function getFilesForRoute(
|
||||
assetPrefix: string,
|
||||
route: string
|
||||
): Promise<RouteFiles> {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const scriptUrl =
|
||||
assetPrefix +
|
||||
'/_next/static/chunks/pages' +
|
||||
encodeURIPath(getAssetPathFromRoute(route, '.js')) +
|
||||
getAssetQueryString()
|
||||
return Promise.resolve({
|
||||
scripts: [__unsafeCreateTrustedScriptURL(scriptUrl)],
|
||||
// Styles are handled by `style-loader` in development:
|
||||
css: [],
|
||||
})
|
||||
}
|
||||
return getClientBuildManifest().then((manifest) => {
|
||||
if (!(route in manifest)) {
|
||||
throw markAssetError(new Error(`Failed to lookup route: ${route}`))
|
||||
}
|
||||
const allFiles = manifest[route].map(
|
||||
(entry) => assetPrefix + '/_next/' + encodeURIPath(entry)
|
||||
)
|
||||
return {
|
||||
scripts: allFiles
|
||||
.filter((v) => v.endsWith('.js'))
|
||||
.map((v) => __unsafeCreateTrustedScriptURL(v) + getAssetQueryString()),
|
||||
css: allFiles
|
||||
.filter((v) => v.endsWith('.css'))
|
||||
.map((v) => v + getAssetQueryString()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function createRouteLoader(assetPrefix: string): RouteLoader {
|
||||
const entrypoints: Map<string, Future<RouteEntrypoint> | RouteEntrypoint> =
|
||||
new Map()
|
||||
const loadedScripts: Map<string, Promise<unknown>> = new Map()
|
||||
const styleSheets: Map<string, Promise<RouteStyleSheet>> = new Map()
|
||||
const routes: Map<string, Future<RouteLoaderEntry> | RouteLoaderEntry> =
|
||||
new Map()
|
||||
|
||||
function maybeExecuteScript(
|
||||
src: TrustedScriptURL | string
|
||||
): Promise<unknown> {
|
||||
// With HMR we might need to "reload" scripts when they are
|
||||
// disposed and readded. Executing scripts twice has no functional
|
||||
// differences
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
let prom: Promise<unknown> | undefined = loadedScripts.get(src.toString())
|
||||
if (prom) {
|
||||
return prom
|
||||
}
|
||||
|
||||
// Skip executing script if it's already in the DOM:
|
||||
if (document.querySelector(`script[src^="${src}"]`)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
loadedScripts.set(src.toString(), (prom = appendScript(src)))
|
||||
return prom
|
||||
} else {
|
||||
return appendScript(src)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchStyleSheet(href: string): Promise<RouteStyleSheet> {
|
||||
let prom: Promise<RouteStyleSheet> | undefined = styleSheets.get(href)
|
||||
if (prom) {
|
||||
return prom
|
||||
}
|
||||
|
||||
styleSheets.set(
|
||||
href,
|
||||
(prom = fetch(href, { credentials: 'same-origin' })
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load stylesheet: ${href}`)
|
||||
}
|
||||
return res.text().then((text) => ({ href: href, content: text }))
|
||||
})
|
||||
.catch((err) => {
|
||||
throw markAssetError(err)
|
||||
}))
|
||||
)
|
||||
return prom
|
||||
}
|
||||
|
||||
return {
|
||||
whenEntrypoint(route: string) {
|
||||
return withFuture(route, entrypoints)
|
||||
},
|
||||
onEntrypoint(route: string, execute: undefined | (() => unknown)) {
|
||||
;(execute
|
||||
? Promise.resolve()
|
||||
.then(() => execute())
|
||||
.then(
|
||||
(exports: any) => ({
|
||||
component: (exports && exports.default) || exports,
|
||||
exports: exports,
|
||||
}),
|
||||
(err) => ({ error: err })
|
||||
)
|
||||
: Promise.resolve(undefined)
|
||||
).then((input: RouteEntrypoint | undefined) => {
|
||||
const old = entrypoints.get(route)
|
||||
if (old && 'resolve' in old) {
|
||||
if (input) {
|
||||
entrypoints.set(route, input)
|
||||
old.resolve(input)
|
||||
}
|
||||
} else {
|
||||
if (input) {
|
||||
entrypoints.set(route, input)
|
||||
} else {
|
||||
entrypoints.delete(route)
|
||||
}
|
||||
// when this entrypoint has been resolved before
|
||||
// the route is outdated and we want to invalidate
|
||||
// this cache entry
|
||||
routes.delete(route)
|
||||
}
|
||||
})
|
||||
},
|
||||
loadRoute(route: string, prefetch?: boolean) {
|
||||
return withFuture<RouteLoaderEntry>(route, routes, () => {
|
||||
let devBuildPromiseResolve: () => void
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
devBuildPromise = new Promise<void>((resolve) => {
|
||||
devBuildPromiseResolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
return resolvePromiseWithTimeout(
|
||||
getFilesForRoute(assetPrefix, route)
|
||||
.then(({ scripts, css }) => {
|
||||
return Promise.all([
|
||||
entrypoints.has(route)
|
||||
? []
|
||||
: Promise.all(scripts.map(maybeExecuteScript)),
|
||||
Promise.all(css.map(fetchStyleSheet)),
|
||||
] as const)
|
||||
})
|
||||
.then((res) => {
|
||||
return this.whenEntrypoint(route).then((entrypoint) => ({
|
||||
entrypoint,
|
||||
styles: res[1],
|
||||
}))
|
||||
}),
|
||||
MS_MAX_IDLE_DELAY,
|
||||
markAssetError(new Error(`Route did not complete loading: ${route}`))
|
||||
)
|
||||
.then(({ entrypoint, styles }) => {
|
||||
const res: RouteLoaderEntry = Object.assign<
|
||||
{ styles: RouteStyleSheet[] },
|
||||
RouteEntrypoint
|
||||
>({ styles: styles! }, entrypoint)
|
||||
return 'error' in entrypoint ? entrypoint : res
|
||||
})
|
||||
.catch((err) => {
|
||||
if (prefetch) {
|
||||
// we don't want to cache errors during prefetch
|
||||
throw err
|
||||
}
|
||||
return { error: err }
|
||||
})
|
||||
.finally(() => devBuildPromiseResolve?.())
|
||||
})
|
||||
},
|
||||
prefetch(route: string): Promise<void> {
|
||||
// https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
|
||||
// License: Apache 2.0
|
||||
let cn
|
||||
if ((cn = (navigator as any).connection)) {
|
||||
// Don't prefetch if using 2G or if Save-Data is enabled.
|
||||
if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
|
||||
}
|
||||
return getFilesForRoute(assetPrefix, route)
|
||||
.then((output) =>
|
||||
Promise.all(
|
||||
canPrefetch
|
||||
? output.scripts.map((script) =>
|
||||
prefetchViaDom(script.toString(), 'script')
|
||||
)
|
||||
: []
|
||||
)
|
||||
)
|
||||
.then(() => {
|
||||
requestIdleCallback(() => this.loadRoute(route, true).catch(() => {}))
|
||||
})
|
||||
.catch(
|
||||
// swallow prefetch errors
|
||||
() => {}
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
195
client/router.ts
Normal file
195
client/router.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/* global window */
|
||||
import React from 'react'
|
||||
import Router from '../shared/lib/router/router'
|
||||
import type { NextRouter } from '../shared/lib/router/router'
|
||||
import { RouterContext } from '../shared/lib/router-context.shared-runtime'
|
||||
import isError from '../lib/is-error'
|
||||
|
||||
type SingletonRouterBase = {
|
||||
router: Router | null
|
||||
readyCallbacks: Array<() => any>
|
||||
ready(cb: () => any): void
|
||||
}
|
||||
|
||||
export { Router }
|
||||
|
||||
export type { NextRouter }
|
||||
|
||||
export type SingletonRouter = SingletonRouterBase & NextRouter
|
||||
|
||||
const singletonRouter: SingletonRouterBase = {
|
||||
router: null, // holds the actual router instance
|
||||
readyCallbacks: [],
|
||||
ready(callback: () => void) {
|
||||
if (this.router) return callback()
|
||||
if (typeof window !== 'undefined') {
|
||||
this.readyCallbacks.push(callback)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Create public properties and methods of the router in the singletonRouter
|
||||
const urlPropertyFields = [
|
||||
'pathname',
|
||||
'route',
|
||||
'query',
|
||||
'asPath',
|
||||
'components',
|
||||
'isFallback',
|
||||
'basePath',
|
||||
'locale',
|
||||
'locales',
|
||||
'defaultLocale',
|
||||
'isReady',
|
||||
'isPreview',
|
||||
'isLocaleDomain',
|
||||
'domainLocales',
|
||||
] as const
|
||||
const routerEvents = [
|
||||
'routeChangeStart',
|
||||
'beforeHistoryChange',
|
||||
'routeChangeComplete',
|
||||
'routeChangeError',
|
||||
'hashChangeStart',
|
||||
'hashChangeComplete',
|
||||
] as const
|
||||
export type RouterEvent = (typeof routerEvents)[number]
|
||||
|
||||
const coreMethodFields = [
|
||||
'push',
|
||||
'replace',
|
||||
'reload',
|
||||
'back',
|
||||
'prefetch',
|
||||
'beforePopState',
|
||||
] as const
|
||||
|
||||
// Events is a static property on the router, the router doesn't have to be initialized to use it
|
||||
Object.defineProperty(singletonRouter, 'events', {
|
||||
get() {
|
||||
return Router.events
|
||||
},
|
||||
})
|
||||
|
||||
function getRouter(): Router {
|
||||
if (!singletonRouter.router) {
|
||||
const message =
|
||||
'No router instance found.\n' +
|
||||
'You should only use "next/router" on the client side of your app.\n'
|
||||
throw new Error(message)
|
||||
}
|
||||
return singletonRouter.router
|
||||
}
|
||||
|
||||
urlPropertyFields.forEach((field) => {
|
||||
// Here we need to use Object.defineProperty because we need to return
|
||||
// the property assigned to the actual router
|
||||
// The value might get changed as we change routes and this is the
|
||||
// proper way to access it
|
||||
Object.defineProperty(singletonRouter, field, {
|
||||
get() {
|
||||
const router = getRouter()
|
||||
return router[field] as string
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
coreMethodFields.forEach((field) => {
|
||||
// We don't really know the types here, so we add them later instead
|
||||
;(singletonRouter as any)[field] = (...args: any[]) => {
|
||||
const router = getRouter() as any
|
||||
return router[field](...args)
|
||||
}
|
||||
})
|
||||
|
||||
routerEvents.forEach((event) => {
|
||||
singletonRouter.ready(() => {
|
||||
Router.events.on(event, (...args) => {
|
||||
const eventField = `on${event.charAt(0).toUpperCase()}${event.substring(
|
||||
1
|
||||
)}`
|
||||
const _singletonRouter = singletonRouter as any
|
||||
if (_singletonRouter[eventField]) {
|
||||
try {
|
||||
_singletonRouter[eventField](...args)
|
||||
} catch (err) {
|
||||
console.error(`Error when running the Router event: ${eventField}`)
|
||||
console.error(
|
||||
isError(err) ? `${err.message}\n${err.stack}` : err + ''
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Export the singletonRouter and this is the public API.
|
||||
export default singletonRouter as SingletonRouter
|
||||
|
||||
// Reexport the withRouter HOC
|
||||
export { default as withRouter } from './with-router'
|
||||
|
||||
/**
|
||||
* This hook gives access the [router object](https://nextjs.org/docs/pages/api-reference/functions/use-router#router-object)
|
||||
* inside the [Pages Router](https://nextjs.org/docs/pages/building-your-application).
|
||||
*
|
||||
* Read more: [Next.js Docs: `useRouter`](https://nextjs.org/docs/pages/api-reference/functions/use-router)
|
||||
*/
|
||||
export function useRouter(): NextRouter {
|
||||
const router = React.useContext(RouterContext)
|
||||
if (!router) {
|
||||
throw new Error(
|
||||
'NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted'
|
||||
)
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a router and assign it as the singleton instance.
|
||||
* This is used in client side when we are initializing the app.
|
||||
* This should **not** be used inside the server.
|
||||
* @internal
|
||||
*/
|
||||
export function createRouter(
|
||||
...args: ConstructorParameters<typeof Router>
|
||||
): Router {
|
||||
singletonRouter.router = new Router(...args)
|
||||
singletonRouter.readyCallbacks.forEach((cb) => cb())
|
||||
singletonRouter.readyCallbacks = []
|
||||
|
||||
return singletonRouter.router
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is used to create the `withRouter` router instance
|
||||
* @internal
|
||||
*/
|
||||
export function makePublicRouterInstance(router: Router): NextRouter {
|
||||
const scopedRouter = router as any
|
||||
const instance = {} as any
|
||||
|
||||
for (const property of urlPropertyFields) {
|
||||
if (typeof scopedRouter[property] === 'object') {
|
||||
instance[property] = Object.assign(
|
||||
Array.isArray(scopedRouter[property]) ? [] : {},
|
||||
scopedRouter[property]
|
||||
) // makes sure query is not stateful
|
||||
continue
|
||||
}
|
||||
|
||||
instance[property] = scopedRouter[property]
|
||||
}
|
||||
|
||||
// Events is a static property on the router, the router doesn't have to be initialized to use it
|
||||
instance.events = Router.events
|
||||
|
||||
coreMethodFields.forEach((field) => {
|
||||
instance[field] = (...args: any[]) => {
|
||||
return scopedRouter[field](...args)
|
||||
}
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
385
client/script.tsx
Normal file
385
client/script.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
'use client'
|
||||
|
||||
import ReactDOM from 'react-dom'
|
||||
import React, { useEffect, useContext, useRef, type JSX } from 'react'
|
||||
import type { ScriptHTMLAttributes } from 'react'
|
||||
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
|
||||
import { setAttributesFromProps } from './set-attributes-from-props'
|
||||
import { requestIdleCallback } from './request-idle-callback'
|
||||
|
||||
const ScriptCache = new Map()
|
||||
const LoadCache = new Set()
|
||||
|
||||
export interface ScriptProps extends ScriptHTMLAttributes<HTMLScriptElement> {
|
||||
strategy?: 'afterInteractive' | 'lazyOnload' | 'beforeInteractive' | 'worker'
|
||||
id?: string
|
||||
onLoad?: (e: any) => void
|
||||
onReady?: () => void | null
|
||||
onError?: (e: any) => void
|
||||
children?: React.ReactNode
|
||||
stylesheets?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `ScriptProps` instead.
|
||||
*/
|
||||
export type Props = ScriptProps
|
||||
|
||||
const insertStylesheets = (stylesheets: string[]) => {
|
||||
// Case 1: Styles for afterInteractive/lazyOnload with appDir injected via handleClientScriptLoad
|
||||
//
|
||||
// Using ReactDOM.preinit to feature detect appDir and inject styles
|
||||
// Stylesheets might have already been loaded if initialized with Script component
|
||||
// Re-inject styles here to handle scripts loaded via handleClientScriptLoad
|
||||
// ReactDOM.preinit handles dedup and ensures the styles are loaded only once
|
||||
if (ReactDOM.preinit) {
|
||||
stylesheets.forEach((stylesheet: string) => {
|
||||
ReactDOM.preinit(stylesheet, { as: 'style' })
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Case 2: Styles for afterInteractive/lazyOnload with pages injected via handleClientScriptLoad
|
||||
//
|
||||
// We use this function to load styles when appdir is not detected
|
||||
// TODO: Use React float APIs to load styles once available for pages dir
|
||||
if (typeof window !== 'undefined') {
|
||||
let head = document.head
|
||||
stylesheets.forEach((stylesheet: string) => {
|
||||
let link = document.createElement('link')
|
||||
|
||||
link.type = 'text/css'
|
||||
link.rel = 'stylesheet'
|
||||
link.href = stylesheet
|
||||
|
||||
head.appendChild(link)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadScript = (props: ScriptProps): void => {
|
||||
const {
|
||||
src,
|
||||
id,
|
||||
onLoad = () => {},
|
||||
onReady = null,
|
||||
dangerouslySetInnerHTML,
|
||||
children = '',
|
||||
strategy = 'afterInteractive',
|
||||
onError,
|
||||
stylesheets,
|
||||
} = props
|
||||
|
||||
const cacheKey = id || src
|
||||
|
||||
// Script has already loaded
|
||||
if (cacheKey && LoadCache.has(cacheKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Contents of this script are already loading/loaded
|
||||
if (ScriptCache.has(src)) {
|
||||
LoadCache.add(cacheKey)
|
||||
// It is possible that multiple `next/script` components all have same "src", but has different "onLoad"
|
||||
// This is to make sure the same remote script will only load once, but "onLoad" are executed in order
|
||||
ScriptCache.get(src).then(onLoad, onError)
|
||||
return
|
||||
}
|
||||
|
||||
/** Execute after the script first loaded */
|
||||
const afterLoad = () => {
|
||||
// Run onReady for the first time after load event
|
||||
if (onReady) {
|
||||
onReady()
|
||||
}
|
||||
// add cacheKey to LoadCache when load successfully
|
||||
LoadCache.add(cacheKey)
|
||||
}
|
||||
|
||||
const el = document.createElement('script')
|
||||
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
el.addEventListener('load', function (e) {
|
||||
resolve()
|
||||
if (onLoad) {
|
||||
onLoad.call(this, e)
|
||||
}
|
||||
afterLoad()
|
||||
})
|
||||
el.addEventListener('error', function (e) {
|
||||
reject(e)
|
||||
})
|
||||
}).catch(function (e) {
|
||||
if (onError) {
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
|
||||
if (dangerouslySetInnerHTML) {
|
||||
// Casting since lib.dom.d.ts doesn't have TrustedHTML yet.
|
||||
el.innerHTML = (dangerouslySetInnerHTML.__html as string) || ''
|
||||
|
||||
afterLoad()
|
||||
} else if (children) {
|
||||
el.textContent =
|
||||
typeof children === 'string'
|
||||
? children
|
||||
: Array.isArray(children)
|
||||
? children.join('')
|
||||
: ''
|
||||
|
||||
afterLoad()
|
||||
} else if (src) {
|
||||
el.src = src
|
||||
// do not add cacheKey into LoadCache for remote script here
|
||||
// cacheKey will be added to LoadCache when it is actually loaded (see loadPromise above)
|
||||
|
||||
ScriptCache.set(src, loadPromise)
|
||||
}
|
||||
|
||||
setAttributesFromProps(el, props)
|
||||
|
||||
if (strategy === 'worker') {
|
||||
el.setAttribute('type', 'text/partytown')
|
||||
}
|
||||
|
||||
el.setAttribute('data-nscript', strategy)
|
||||
|
||||
// Load styles associated with this script
|
||||
if (stylesheets) {
|
||||
insertStylesheets(stylesheets)
|
||||
}
|
||||
|
||||
document.body.appendChild(el)
|
||||
}
|
||||
|
||||
export function handleClientScriptLoad(props: ScriptProps) {
|
||||
const { strategy = 'afterInteractive' } = props
|
||||
if (strategy === 'lazyOnload') {
|
||||
window.addEventListener('load', () => {
|
||||
requestIdleCallback(() => loadScript(props))
|
||||
})
|
||||
} else {
|
||||
loadScript(props)
|
||||
}
|
||||
}
|
||||
|
||||
function loadLazyScript(props: ScriptProps) {
|
||||
if (document.readyState === 'complete') {
|
||||
requestIdleCallback(() => loadScript(props))
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
requestIdleCallback(() => loadScript(props))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function addBeforeInteractiveToCache() {
|
||||
const scripts = [
|
||||
...document.querySelectorAll('[data-nscript="beforeInteractive"]'),
|
||||
...document.querySelectorAll('[data-nscript="beforePageRender"]'),
|
||||
]
|
||||
scripts.forEach((script) => {
|
||||
const cacheKey = script.id || script.getAttribute('src')
|
||||
LoadCache.add(cacheKey)
|
||||
})
|
||||
}
|
||||
|
||||
export function initScriptLoader(scriptLoaderItems: ScriptProps[]) {
|
||||
scriptLoaderItems.forEach(handleClientScriptLoad)
|
||||
addBeforeInteractiveToCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a third-party scripts in an optimized way.
|
||||
*
|
||||
* Read more: [Next.js Docs: `next/script`](https://nextjs.org/docs/app/api-reference/components/script)
|
||||
*/
|
||||
function Script(props: ScriptProps): JSX.Element | null {
|
||||
const {
|
||||
id,
|
||||
src = '',
|
||||
onLoad = () => {},
|
||||
onReady = null,
|
||||
strategy = 'afterInteractive',
|
||||
onError,
|
||||
stylesheets,
|
||||
...restProps
|
||||
} = props
|
||||
|
||||
// Context is available only during SSR
|
||||
let { updateScripts, scripts, getIsSsr, appDir, nonce } =
|
||||
useContext(HeadManagerContext)
|
||||
|
||||
// if a nonce is explicitly passed to the script tag, favor that over the automatic handling
|
||||
nonce = restProps.nonce || nonce
|
||||
|
||||
/**
|
||||
* - First mount:
|
||||
* 1. The useEffect for onReady executes
|
||||
* 2. hasOnReadyEffectCalled.current is false, but the script hasn't loaded yet (not in LoadCache)
|
||||
* onReady is skipped, set hasOnReadyEffectCalled.current to true
|
||||
* 3. The useEffect for loadScript executes
|
||||
* 4. hasLoadScriptEffectCalled.current is false, loadScript executes
|
||||
* Once the script is loaded, the onLoad and onReady will be called by then
|
||||
* [If strict mode is enabled / is wrapped in <OffScreen /> component]
|
||||
* 5. The useEffect for onReady executes again
|
||||
* 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped
|
||||
* 7. The useEffect for loadScript executes again
|
||||
* 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped
|
||||
*
|
||||
* - Second mount:
|
||||
* 1. The useEffect for onReady executes
|
||||
* 2. hasOnReadyEffectCalled.current is false, but the script has already loaded (found in LoadCache)
|
||||
* onReady is called, set hasOnReadyEffectCalled.current to true
|
||||
* 3. The useEffect for loadScript executes
|
||||
* 4. The script is already loaded, loadScript bails out
|
||||
* [If strict mode is enabled / is wrapped in <OffScreen /> component]
|
||||
* 5. The useEffect for onReady executes again
|
||||
* 6. hasOnReadyEffectCalled.current is true, so entire effect is skipped
|
||||
* 7. The useEffect for loadScript executes again
|
||||
* 8. hasLoadScriptEffectCalled.current is true, so entire effect is skipped
|
||||
*/
|
||||
const hasOnReadyEffectCalled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = id || src
|
||||
if (!hasOnReadyEffectCalled.current) {
|
||||
// Run onReady if script has loaded before but component is re-mounted
|
||||
if (onReady && cacheKey && LoadCache.has(cacheKey)) {
|
||||
onReady()
|
||||
}
|
||||
|
||||
hasOnReadyEffectCalled.current = true
|
||||
}
|
||||
}, [onReady, id, src])
|
||||
|
||||
const hasLoadScriptEffectCalled = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoadScriptEffectCalled.current) {
|
||||
if (strategy === 'afterInteractive') {
|
||||
loadScript(props)
|
||||
} else if (strategy === 'lazyOnload') {
|
||||
loadLazyScript(props)
|
||||
}
|
||||
|
||||
hasLoadScriptEffectCalled.current = true
|
||||
}
|
||||
}, [props, strategy])
|
||||
|
||||
if (strategy === 'beforeInteractive' || strategy === 'worker') {
|
||||
if (updateScripts) {
|
||||
scripts[strategy] = (scripts[strategy] || []).concat([
|
||||
{
|
||||
id,
|
||||
src,
|
||||
onLoad,
|
||||
onReady,
|
||||
onError,
|
||||
...restProps,
|
||||
nonce,
|
||||
},
|
||||
])
|
||||
updateScripts(scripts)
|
||||
} else if (getIsSsr && getIsSsr()) {
|
||||
// Script has already loaded during SSR
|
||||
LoadCache.add(id || src)
|
||||
} else if (getIsSsr && !getIsSsr()) {
|
||||
loadScript({
|
||||
...props,
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For the app directory, we need React Float to preload these scripts.
|
||||
if (appDir) {
|
||||
// Injecting stylesheets here handles beforeInteractive and worker scripts correctly
|
||||
// For other strategies injecting here ensures correct stylesheet order
|
||||
// ReactDOM.preinit handles loading the styles in the correct order,
|
||||
// also ensures the stylesheet is loaded only once and in a consistent manner
|
||||
//
|
||||
// Case 1: Styles for beforeInteractive/worker with appDir - handled here
|
||||
// Case 2: Styles for beforeInteractive/worker with pages dir - Not handled yet
|
||||
// Case 3: Styles for afterInteractive/lazyOnload with appDir - handled here
|
||||
// Case 4: Styles for afterInteractive/lazyOnload with pages dir - handled in insertStylesheets function
|
||||
if (stylesheets) {
|
||||
stylesheets.forEach((styleSrc) => {
|
||||
ReactDOM.preinit(styleSrc, { as: 'style' })
|
||||
})
|
||||
}
|
||||
|
||||
// Before interactive scripts need to be loaded by Next.js' runtime instead
|
||||
// of native <script> tags, because they no longer have `defer`.
|
||||
if (strategy === 'beforeInteractive') {
|
||||
if (!src) {
|
||||
// For inlined scripts, we put the content in `children`.
|
||||
if (restProps.dangerouslySetInnerHTML) {
|
||||
// Casting since lib.dom.d.ts doesn't have TrustedHTML yet.
|
||||
restProps.children = restProps.dangerouslySetInnerHTML
|
||||
.__html as string
|
||||
delete restProps.dangerouslySetInnerHTML
|
||||
}
|
||||
|
||||
return (
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
|
||||
0,
|
||||
{ ...restProps, id },
|
||||
])})`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
ReactDOM.preload(
|
||||
src,
|
||||
restProps.integrity
|
||||
? {
|
||||
as: 'script',
|
||||
integrity: restProps.integrity,
|
||||
nonce,
|
||||
crossOrigin: restProps.crossOrigin,
|
||||
}
|
||||
: { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
|
||||
)
|
||||
return (
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(self.__next_s=self.__next_s||[]).push(${JSON.stringify([
|
||||
src,
|
||||
{ ...restProps, id },
|
||||
])})`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (strategy === 'afterInteractive') {
|
||||
if (src) {
|
||||
// @ts-ignore
|
||||
ReactDOM.preload(
|
||||
src,
|
||||
restProps.integrity
|
||||
? {
|
||||
as: 'script',
|
||||
integrity: restProps.integrity,
|
||||
nonce,
|
||||
crossOrigin: restProps.crossOrigin,
|
||||
}
|
||||
: { as: 'script', nonce, crossOrigin: restProps.crossOrigin }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
Object.defineProperty(Script, '__nextScript', { value: true })
|
||||
|
||||
export default Script
|
||||
59
client/set-attributes-from-props.ts
Normal file
59
client/set-attributes-from-props.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
const DOMAttributeNames: Record<string, string> = {
|
||||
acceptCharset: 'accept-charset',
|
||||
className: 'class',
|
||||
htmlFor: 'for',
|
||||
httpEquiv: 'http-equiv',
|
||||
noModule: 'noModule',
|
||||
}
|
||||
|
||||
const ignoreProps = [
|
||||
'onLoad',
|
||||
'onReady',
|
||||
'dangerouslySetInnerHTML',
|
||||
'children',
|
||||
'onError',
|
||||
'strategy',
|
||||
'stylesheets',
|
||||
]
|
||||
|
||||
function isBooleanScriptAttribute(
|
||||
attr: string
|
||||
): attr is 'async' | 'defer' | 'noModule' {
|
||||
return ['async', 'defer', 'noModule'].includes(attr)
|
||||
}
|
||||
|
||||
export function setAttributesFromProps(el: HTMLElement, props: object) {
|
||||
for (const [p, value] of Object.entries(props)) {
|
||||
if (!props.hasOwnProperty(p)) continue
|
||||
if (ignoreProps.includes(p)) continue
|
||||
|
||||
// we don't render undefined props to the DOM
|
||||
if (value === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attr = DOMAttributeNames[p] || p.toLowerCase()
|
||||
|
||||
if (el.tagName === 'SCRIPT' && isBooleanScriptAttribute(attr)) {
|
||||
// Correctly assign boolean script attributes
|
||||
// https://github.com/vercel/next.js/pull/20748
|
||||
;(el as HTMLScriptElement)[attr] = !!value
|
||||
} else {
|
||||
el.setAttribute(attr, String(value))
|
||||
}
|
||||
|
||||
// Remove falsy non-zero boolean attributes so they are correctly interpreted
|
||||
// (e.g. if we set them to false, this coerces to the string "false", which the browser interprets as true)
|
||||
if (
|
||||
value === false ||
|
||||
(el.tagName === 'SCRIPT' &&
|
||||
isBooleanScriptAttribute(attr) &&
|
||||
(!value || value === 'false'))
|
||||
) {
|
||||
// Call setAttribute before, as we need to set and unset the attribute to override force async:
|
||||
// https://html.spec.whatwg.org/multipage/scripting.html#script-force-async
|
||||
el.setAttribute(attr, '')
|
||||
el.removeAttribute(attr)
|
||||
}
|
||||
}
|
||||
}
|
||||
37
client/trusted-types.ts
Normal file
37
client/trusted-types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Stores the Trusted Types Policy. Starts as undefined and can be set to null
|
||||
* if Trusted Types is not supported in the browser.
|
||||
*/
|
||||
let policy: TrustedTypePolicy | null | undefined
|
||||
|
||||
/**
|
||||
* Getter for the Trusted Types Policy. If it is undefined, it is instantiated
|
||||
* here or set to null if Trusted Types is not supported in the browser.
|
||||
*/
|
||||
function getPolicy() {
|
||||
if (typeof policy === 'undefined' && typeof window !== 'undefined') {
|
||||
policy =
|
||||
window.trustedTypes?.createPolicy('nextjs', {
|
||||
createHTML: (input) => input,
|
||||
createScript: (input) => input,
|
||||
createScriptURL: (input) => input,
|
||||
}) || null
|
||||
}
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsafely promote a string to a TrustedScriptURL, falling back to strings
|
||||
* when Trusted Types are not available.
|
||||
* This is a security-sensitive function; any use of this function
|
||||
* must go through security review. In particular, it must be assured that the
|
||||
* provided string will never cause an XSS vulnerability if used in a context
|
||||
* that will cause a browser to load and execute a resource, e.g. when
|
||||
* assigning to script.src.
|
||||
*/
|
||||
export function __unsafeCreateTrustedScriptURL(
|
||||
url: string
|
||||
): TrustedScriptURL | string {
|
||||
return getPolicy()?.createScriptURL(url) || url
|
||||
}
|
||||
39
client/webpack.ts
Normal file
39
client/webpack.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
declare const __webpack_require__: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
declare let __webpack_public_path__: string
|
||||
|
||||
import { getDeploymentIdQueryOrEmptyString } from '../build/deployment-id'
|
||||
|
||||
// If we have a deployment ID, we need to append it to the webpack chunk names
|
||||
// I am keeping the process check explicit so this can be statically optimized
|
||||
if (process.env.NEXT_DEPLOYMENT_ID) {
|
||||
const suffix = getDeploymentIdQueryOrEmptyString()
|
||||
// eslint-disable-next-line no-undef
|
||||
const getChunkScriptFilename = __webpack_require__.u
|
||||
// eslint-disable-next-line no-undef
|
||||
__webpack_require__.u = (...args: any[]) =>
|
||||
// We enode the chunk filename because our static server matches against and encoded
|
||||
// filename path.
|
||||
getChunkScriptFilename(...args) + suffix
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const getChunkCssFilename = __webpack_require__.k
|
||||
// eslint-disable-next-line no-undef
|
||||
__webpack_require__.k = (...args: any[]) =>
|
||||
getChunkCssFilename(...args) + suffix
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
const getMiniCssFilename = __webpack_require__.miniCssF
|
||||
// eslint-disable-next-line no-undef
|
||||
__webpack_require__.miniCssF = (...args: any[]) =>
|
||||
getMiniCssFilename(...args) + suffix
|
||||
}
|
||||
|
||||
// Ignore the module ID transform in client.
|
||||
;(self as any).__next_set_public_path__ = (path: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
__webpack_public_path__ = path
|
||||
}
|
||||
|
||||
export {}
|
||||
41
client/with-router.tsx
Normal file
41
client/with-router.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, { type JSX } from 'react'
|
||||
import type {
|
||||
BaseContext,
|
||||
NextComponentType,
|
||||
NextPageContext,
|
||||
} from '../shared/lib/utils'
|
||||
import type { NextRouter } from './router'
|
||||
import { useRouter } from './router'
|
||||
|
||||
export type WithRouterProps = {
|
||||
router: NextRouter
|
||||
}
|
||||
|
||||
export type ExcludeRouterProps<P> = Pick<
|
||||
P,
|
||||
Exclude<keyof P, keyof WithRouterProps>
|
||||
>
|
||||
|
||||
export default function withRouter<
|
||||
P extends WithRouterProps,
|
||||
C extends BaseContext = NextPageContext,
|
||||
>(
|
||||
ComposedComponent: NextComponentType<C, any, P>
|
||||
): React.ComponentType<ExcludeRouterProps<P>> {
|
||||
function WithRouterWrapper(props: any): JSX.Element {
|
||||
return <ComposedComponent router={useRouter()} {...props} />
|
||||
}
|
||||
|
||||
WithRouterWrapper.getInitialProps = ComposedComponent.getInitialProps
|
||||
// This is needed to allow checking for custom getInitialProps in _app
|
||||
;(WithRouterWrapper as any).origGetInitialProps = (
|
||||
ComposedComponent as any
|
||||
).origGetInitialProps
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const name =
|
||||
ComposedComponent.displayName || ComposedComponent.name || 'Unknown'
|
||||
WithRouterWrapper.displayName = `withRouter(${name})`
|
||||
}
|
||||
|
||||
return WithRouterWrapper
|
||||
}
|
||||
Reference in New Issue
Block a user