rsnext/packages/next/client/head-manager.ts
Jiachi Liu 16fd15b526
Migrate head side effects to hooks (#37526)
* rewrite head side effects component in hooks
* remove mapping from element to children in head manager since they're already the children of `<Head>`

When move `SideEffect` to hooks, the effects scheduling is earlier than life cycle. We're leverage layout effects and effects at the same time, always cache the latest head updating function in head manager in layout effects, and flush them in the effects. This could help get rid of the promises delaying approach in head manager.

Co-authored-by: Shu Ding <3676859+shuding@users.noreply.github.com>
2022-06-08 11:26:57 +00:00

165 lines
5.3 KiB
TypeScript

export const DOMAttributeNames: Record<string, string> = {
acceptCharset: 'accept-charset',
className: 'class',
htmlFor: 'for',
httpEquiv: 'http-equiv',
noModule: 'noModule',
}
function reactElementToDOM({ type, props }: JSX.Element): HTMLElement {
const el: HTMLElement = document.createElement(type)
for (const p in props) {
if (!props.hasOwnProperty(p)) continue
if (p === 'children' || p === 'dangerouslySetInnerHTML') continue
// we don't render undefined props to the DOM
if (props[p] === undefined) continue
const attr = DOMAttributeNames[p] || p.toLowerCase()
if (
type === 'script' &&
(attr === 'async' || attr === 'defer' || attr === 'noModule')
) {
;(el as HTMLScriptElement)[attr] = !!props[p]
} else {
el.setAttribute(attr, props[p])
}
}
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[]): void {
const headEl = document.getElementsByTagName('head')[0]
const headCountEl: HTMLMetaElement = headEl.querySelector(
'meta[name=next-head-count]'
) as HTMLMetaElement
if (process.env.NODE_ENV !== 'production') {
if (!headCountEl) {
console.error(
'Warning: next-head-count is missing. https://nextjs.org/docs/messages/next-head-count-missing'
)
return
}
}
const headCount = Number(headCountEl.content)
const oldTags: Element[] = []
for (
let i = 0, j = headCountEl.previousElementSibling;
i < headCount;
i++, j = j?.previousElementSibling || null
) {
if (j?.tagName?.toLowerCase() === type) {
oldTags.push(j)
}
}
const newTags = (components.map(reactElementToDOM) as HTMLElement[]).filter(
(newTag) => {
for (let k = 0, len = oldTags.length; k < len; k++) {
const oldTag = oldTags[k]
if (isEqualNode(oldTag, newTag)) {
oldTags.splice(k, 1)
return false
}
}
return true
}
)
oldTags.forEach((t) => t.parentNode?.removeChild(t))
newTags.forEach((t) => headEl.insertBefore(t, headCountEl))
headCountEl.content = (headCount - oldTags.length + newTags.length).toString()
}
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] || [])
})
},
}
}