Repository: vuepress/vuepress-next (ts)

Issue:
[Bug report] Head resources are loaded again when switching pages
### Description

When users set js and css resources through head option in config file, switching pages will cause these resources to relead.

I think this should be bug as we should diff the head list and try to avoid upgrading the unchanged one

### Reproduction

https://dromara.org/fast-request/

### Used Package Manager

npm

### System Info

```shell
Not related
```


Pull request description:
fix(client): avoid updating existing head tags, close #1268

Patch:
diff --git a/packages/client/src/setupUpdateHead.ts b/packages/client/src/setupUpdateHead.ts
index 504cc924..5757426c 100644
--- a/packages/client/src/setupUpdateHead.ts
+++ b/packages/client/src/setupUpdateHead.ts
@@ -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,
@@ -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 = '',
@@ -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,
@@ -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
 }
diff --git a/packages/shared/src/utils/dedupeHead.ts b/packages/shared/src/utils/dedupeHead.ts
index 06dc30c7..3b47a72a 100644
--- a/packages/shared/src/utils/dedupeHead.ts
+++ b/packages/shared/src/utils/dedupeHead.ts
@@ -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)
     }
diff --git a/packages/shared/src/utils/resolveHeadIdentifier.ts b/packages/shared/src/utils/resolveHeadIdentifier.ts
index fff7dc7b..2160862f 100644
--- a/packages/shared/src/utils/resolveHeadIdentifier.ts
+++ b/packages/shared/src/utils/resolveHeadIdentifier.ts
@@ -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,
+  ])
 }


Test patch:
diff --git a/e2e/tests/site-data.cy.ts b/e2e/tests/site-data.cy.ts
index cca6f836..2ed1900b 100644
--- a/e2e/tests/site-data.cy.ts
+++ b/e2e/tests/site-data.cy.ts
@@ -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', () => {
@@ -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', () => {
diff --git a/e2e/tests/update-head.cy.ts b/e2e/tests/update-head.cy.ts
new file mode 100644
index 00000000..0f617c8d
--- /dev/null
+++ b/e2e/tests/update-head.cy.ts
@@ -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')
+  })
+})
diff --git a/packages/shared/tests/resolveHeadIdentifier.spec.ts b/packages/shared/tests/resolveHeadIdentifier.spec.ts
index 6e169b09..db0a1891 100644
--- a/packages/shared/tests/resolveHeadIdentifier.spec.ts
+++ b/packages/shared/tests/resolveHeadIdentifier.spec.ts
@@ -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