Skip to content

Commit

Permalink
fix(client): avoid updating existing head tags (close #1268) (#1314)
Browse files Browse the repository at this point in the history
Co-authored-by: meteorlxy <[email protected]>
  • Loading branch information
Mister-Hope and meteorlxy authored Dec 14, 2023
1 parent 2fe35bb commit bfbab28
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 58 deletions.
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

0 comments on commit bfbab28

Please sign in to comment.