Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(client): avoid updating existing head tags, close #1268 #1314

Merged
merged 13 commits into from
Dec 14, 2023
24 changes: 12 additions & 12 deletions e2e/tests/site-data.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ describe('siteData', () => {

it('title', () => {
cy.title().should('eq', 'VuePress E2E')
cy.get('head title').should('have.text', 'VuePress E2E')
cy.get('head title')
.should('have.length', 1)
.should('have.text', 'VuePress E2E')
})

it('description', () => {
cy.get('head meta[name="description"]').should(
'have.attr',
'content',
'VuePress E2E Test Site',
)
cy.get('head meta[name="description"]')
.should('have.length', 1)
.should('have.attr', 'content', 'VuePress E2E Test Site')
})

it('head', () => {
Expand Down Expand Up @@ -48,15 +48,15 @@ describe('siteData', () => {

it('title', () => {
cy.title().should('eq', 'VuePress E2E')
cy.get('head title').should('have.text', 'VuePress E2E')
cy.get('head title')
.should('have.length', 1)
.should('have.text', 'VuePress E2E')
})

it('description', () => {
cy.get('head meta[name="description"]').should(
'have.attr',
'content',
'VuePress E2E 测试站点',
)
cy.get('head meta[name="description"]')
.should('have.length', 1)
.should('have.attr', 'content', 'VuePress E2E 测试站点')
})

it('head', () => {
Expand Down
59 changes: 59 additions & 0 deletions e2e/tests/update-head.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
describe('updateHead', () => {
it('should update head correctly', () => {
// en-US
cy.visit('/')

// lang
cy.get('html').should('have.attr', 'lang', 'en-US')
// title
cy.title().should('eq', 'VuePress E2E')
cy.get('head title')
.should('have.length', 1)
.should('have.text', 'VuePress E2E')
// description
cy.get('head meta[name="description"]')
.should('have.length', 1)
.should('have.attr', 'content', 'VuePress E2E Test Site')
// head
cy.get('head meta[name="foo"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foo')
cy.get('head meta[name="bar"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foobar')
cy.get('head meta[name="baz"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foobar baz')
cy.get('head meta[name="foo-en"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foo-en')

// navigate to zh-CN
cy.get('.e2e-theme-nav a').contains('zh-CN').click()

// lang
cy.get('html').should('have.attr', 'lang', 'zh-CN')
// title
cy.title().should('eq', 'VuePress E2E')
cy.get('head title')
.should('have.length', 1)
.should('have.text', 'VuePress E2E')
// description
cy.get('head meta[name="description"]')
.should('have.length', 1)
.should('have.attr', 'content', 'VuePress E2E 测试站点')
// head
cy.get('head meta[name="foo"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foo')
cy.get('head meta[name="bar"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foobar zh')
cy.get('head meta[name="baz"]')
.should('have.length', 1)
.should('have.attr', 'content', 'baz')
cy.get('head meta[name="foo-zh"]')
.should('have.length', 1)
.should('have.attr', 'content', 'foo-zh')
})
})
108 changes: 74 additions & 34 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,86 @@ export const setupUpdateHead = (): void => {
return
}

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

// load current head tags from DOM
const loadHead = (): void => {
/**
* Take over the existing head elements
*/
const takeOverHeadElements = (): void => {
head.value.forEach((item) => {
const tag = queryHeadTag(item)
if (tag) {
headTags.value.push(tag)
const headElement = queryHeadElement(item)
if (headElement) {
managedHeadElements.push(headElement)
}
})
}

// update html lang attribute and head tags to DOM
/**
* Generate head elements from current head config
*/
const generateHeadElements = (): HTMLElement[] => {
const result: HTMLElement[] = []
head.value.forEach((item) => {
const headElement = createHeadElement(item)
if (headElement) {
result.push(headElement)
}
})
return result
}

/**
* Update head elements
*/
const updateHead: UpdateHead = () => {
// set html lang attribute
document.documentElement.lang = lang.value

headTags.value.forEach((item) => {
if (item.parentNode === document.head) {
document.head.removeChild(item)
}
})
headTags.value.splice(0, headTags.value.length)
// generate new head elements from current head config
const newHeadElements = generateHeadElements()

head.value.forEach((item) => {
const tag = createHeadTag(item)
if (tag !== null) {
document.head.appendChild(tag)
headTags.value.push(tag)
// find the intersection of old and new head elements
managedHeadElements.forEach((oldEl, oldIndex) => {
const matchedIndex = newHeadElements.findIndex((newEl) =>
oldEl.isEqualNode(newEl),
)
// remove the non-intersection from old elements
if (matchedIndex === -1) {
oldEl.remove()
// use delete to make the index consistent
delete managedHeadElements[oldIndex]
}
// keep the intersection in old elements, and remove it from new elements
else {
// use splice to avoid empty item in next loop
newHeadElements.splice(matchedIndex, 1)
}
})
// append the rest new elements to head
newHeadElements.forEach((el) => document.head.appendChild(el))
// update managed head elements
managedHeadElements = [
// filter out empty deleted items
...managedHeadElements.filter((item) => !!item),
...newHeadElements,
]
}
provide(updateHeadSymbol, updateHead)

onMounted(() => {
loadHead()
updateHead()
watch(() => head.value, updateHead)
// in production, the initial head elements are already pre-rendered,
// so we need to skip the first update and take over the existing elements.
if (!__VUEPRESS_DEV__) {
takeOverHeadElements()
}
watch(head, updateHead, { immediate: __VUEPRESS_DEV__ })
})
}

/**
* Query the matched head tag of head config
* Query the matched head element of head config
*/
export const queryHeadTag = ([
export const queryHeadElement = ([
tagName,
attrs,
content = '',
Expand All @@ -86,15 +122,19 @@ export const queryHeadTag = ([
.join('')

const selector = `head > ${tagName}${attrsSelector}`
const tags = Array.from(document.querySelectorAll<HTMLElement>(selector))
const matchedTag = tags.find((item) => item.innerText === content)
return matchedTag || null
const headElements = Array.from(
document.querySelectorAll<HTMLElement>(selector),
)
const matchedHeadElement = headElements.find(
(item) => item.innerText === content,
)
return matchedHeadElement || null
}

/**
* Create head tag from head config
* Create head element from head config
*/
export const createHeadTag = ([
export const createHeadElement = ([
tagName,
attrs,
content,
Expand All @@ -104,23 +144,23 @@ export const createHeadTag = ([
}

// create element
const tag = document.createElement(tagName)
const headElement = document.createElement(tagName)

// set attributes
if (isPlainObject(attrs)) {
Object.entries(attrs).forEach(([key, value]) => {
if (isString(value)) {
tag.setAttribute(key, value)
headElement.setAttribute(key, value)
} else if (value === true) {
tag.setAttribute(key, '')
headElement.setAttribute(key, '')
}
})
}

// set content
if (isString(content)) {
tag.appendChild(document.createTextNode(content))
headElement.appendChild(document.createTextNode(content))
}

return tag
return headElement
}
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
41 changes: 30 additions & 11 deletions packages/shared/src/utils/resolveHeadIdentifier.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,46 @@
import type { HeadConfig } from '../types/index.js'

const TAGS_ALLOWED = ['link', 'meta', 'script', 'style', 'noscript', 'template']
const TAGS_UNIQUE = ['title', 'base']

/**
* 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 unique tags
if (TAGS_UNIQUE.includes(tag)) {
return tag
}

// avoid disallowed tags
if (!TAGS_ALLOWED.includes(tag)) {
return 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)) {
return tag
}

// avoid duplicated `<template>` with same `id`
if (tag === 'template' && attrs.id) {
return `${tag}.${attrs.id}`
}

return JSON.stringify([tag, attrs, content])
return JSON.stringify([
tag,
Object.entries(attrs)
.map(([key, value]) => {
// normalize boolean attributes
if (typeof value === 'boolean') {
return value ? [key, ''] : null
}
return [key, value]
})
.filter((item): item is [string, string] => item != null)
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)),
content,
])
}
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