Skip to content

Commit

Permalink
feat(client): avoid updating same head tag
Browse files Browse the repository at this point in the history
  • Loading branch information
Mister-Hope committed Nov 20, 2023
1 parent 009501d commit 73bcf77
Show file tree
Hide file tree
Showing 4 changed files with 106 additions and 26 deletions.
45 changes: 27 additions & 18 deletions packages/client/src/setupUpdateHead.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { isPlainObject, isString } from '@vuepress/shared'
import type { HeadConfig, VuepressSSRContext } from '@vuepress/shared'
import { onMounted, provide, ref, useSSRContext, watch } from 'vue'
import { onMounted, provide, useSSRContext, watch } from 'vue'
import {
updateHeadSymbol,
usePageHead,
Expand All @@ -25,50 +25,59 @@ export const setupUpdateHead = (): void => {
return
}

const headTags = ref<HTMLElement[]>([])
const managedHeadElements: HTMLElement[] = []

// load current head tags from DOM
const loadHead = (): void => {
head.value.forEach((item) => {
const tag = queryHeadTag(item)
if (tag) {
headTags.value.push(tag)
}
})
managedHeadElements.push(
...(head.value.map(queryHeadElement).filter(Boolean) as HTMLElement[]),
)
}

// update html lang attribute and head tags to DOM
const updateHead: UpdateHead = () => {
document.documentElement.lang = lang.value

headTags.value.forEach((item) => {
managedHeadElements.forEach((item) => {
if (item.parentNode === document.head) {
document.head.removeChild(item)
}
})
headTags.value.splice(0, headTags.value.length)

head.value.forEach((item) => {
const tag = createHeadTag(item)
if (tag !== null) {
document.head.appendChild(tag)
headTags.value.push(tag)
const newHeadElements = head.value
.map(createHeadElement)
.filter(Boolean) as HTMLElement[]

managedHeadElements.forEach((el, index) => {
const matchedIndex = newHeadElements.findIndex(
(newEl) => newEl?.isEqualNode(el ?? null),
)

if (matchedIndex !== -1) {
delete newHeadElements[matchedIndex]
} else {
el?.remove()
delete managedHeadElements[index]
}
})

newHeadElements.forEach((el) => {
document.head.appendChild(el)
})
managedHeadElements.push(...newHeadElements)
}
provide(updateHeadSymbol, updateHead)

onMounted(() => {
loadHead()
updateHead()
watch(() => head.value, updateHead)
})
}

/**
* Query the matched head tag of head config
*/
export const queryHeadTag = ([
export const queryHeadElement = ([
tagName,
attrs,
content = '',
Expand All @@ -94,7 +103,7 @@ export const queryHeadTag = ([
/**
* Create head tag from head config
*/
export const createHeadTag = ([
export const createHeadElement = ([
tagName,
attrs,
content,
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/utils/dedupeHead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const dedupeHead = (head: HeadConfig[]): HeadConfig[] => {

head.forEach((item) => {
const identifier = resolveHeadIdentifier(item)
if (!identifierSet.has(identifier)) {
if (identifier && !identifierSet.has(identifier)) {
identifierSet.add(identifier)
result.push(item)
}
Expand Down
37 changes: 30 additions & 7 deletions packages/shared/src/utils/resolveHeadIdentifier.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import type { HeadConfig } from '../types/index.js'

const onlyTags = ['title', 'base']
const allowedTags = ['link', 'meta', 'script', 'style', 'noscript', 'template']

/**
* Resolve identifier of a tag, to avoid duplicated tags in `<head>`
*/
export const resolveHeadIdentifier = ([
tag,
attrs,
content,
]: HeadConfig): string => {
export const resolveHeadIdentifier = ([tag, attrs, content]: HeadConfig):
| string
| null => {
// avoid duplicated `<meta>` with same `name`
if (tag === 'meta' && attrs.name) {
return `${tag}.${attrs.name}`
}

// there should be only one `<title>` or `<base>`
if (['title', 'base'].includes(tag)) {
if (onlyTags.includes(tag)) {
return tag
}

Expand All @@ -23,5 +24,27 @@ export const resolveHeadIdentifier = ([
return `${tag}.${attrs.id}`
}

return JSON.stringify([tag, attrs, content])
if (allowedTags.includes(tag)) {
return JSON.stringify([
tag,
Object.fromEntries(
Object.entries(attrs)
// handle boolean attributes
.map(([key, value]) =>
typeof value === 'boolean'
? value
? [key, '']
: null
: [key, value],
)
.filter((item): item is [string, string] => item != null)
// sort keys
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)),
),
content,
])
}

// tags are not allowed
return null
}
48 changes: 48 additions & 0 deletions packages/shared/tests/resolveHeadIdentifier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,54 @@ describe('shared > resolveHeadIdentifier', () => {
expect(templateFooBaz).not.toBe(templateBarBar)
})

it('should resolve same identifiers of same HeadConfig', () => {
const style1 = resolveHeadIdentifier([
'style',
{ id: 'foo' },
`body { color: red; }`,
])
const style2 = resolveHeadIdentifier([
'style',
{ id: 'foo' },
`body { color: red; }`,
])
const link1 = resolveHeadIdentifier([
'link',
{ href: 'https://example.com', defer: '' },
])
const link2 = resolveHeadIdentifier([
'link',
{ href: 'https://example.com', defer: true },
])
const link3 = resolveHeadIdentifier([
'link',
{ defer: '', href: 'https://example.com' },
])
const link4 = resolveHeadIdentifier([
'link',
{ defer: true, href: 'https://example.com' },
])
const link5 = resolveHeadIdentifier([
'link',
{ href: 'https://example.com' },
])
const link6 = resolveHeadIdentifier([
'link',
{ href: 'https://example.com', defer: false },
])
const link7 = resolveHeadIdentifier([
'link',
{ defer: false, href: 'https://example.com' },
])

expect(style1).toBe(style2)
expect(link1).toBe(link2)
expect(link1).toBe(link3)
expect(link1).toBe(link4)
expect(link5).toBe(link6)
expect(link5).toBe(link7)
})

it('should resolve identifiers correctly', () => {
const head: HeadConfig[] = [
// 1
Expand Down

0 comments on commit 73bcf77

Please sign in to comment.