Skip to content

Commit 002cfae

Browse files
committed
feat: improved side effect patching
1 parent 177eb4d commit 002cfae

31 files changed

+520
-383
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Universal document <head> tag manager. Tiny, adaptable and full featured.
3131

3232
- 💎 Fully typed augmentable Schema powered by [zhead](https://github.com/harlan-zw/zhead)
3333
- 🧑‍🤝‍🧑 Side-effect based DOM patching, plays nicely your existing other tags and attributes
34-
- 🤝 Built for everyone: Vue, React (soon), Svelte (soon), etc.
34+
- 🤝 Built for everyone: Vue, React (soon), Svelte (soon), (more soon).
3535
- 🚀 Optimised, tiny SSR and DOM bundles
3636
- 🖥️ `useServerHead` for 0kb runtime head management
3737
- 🍣 Intuitive deduping, sorting, title templates, class merging and more

packages/unhead/src/createHead.ts

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Head, HeadTag } from '@unhead/schema'
33
import { setActiveHead } from './state'
44
import type { CreateHeadOptions, HeadClient, HeadEntry, HeadHooks, SideEffectsRecord } from './types'
55
import type { HeadPlugin } from './plugin'
6-
import { dedupePlugin, sortPlugin, titleTemplatePlugin } from './plugin'
6+
import { DedupesTagsPlugin, SortTagsPlugin, TitleTemplatePlugin } from './plugin'
77
import { normaliseEntryTags } from './normalise'
88

99
export function createHead<T extends {} = Head>(options: CreateHeadOptions<T> = {}) {
@@ -18,16 +18,15 @@ export function createHead<T extends {} = Head>(options: CreateHeadOptions<T> =
1818

1919
const plugins: HeadPlugin<any>[] = [
2020
// order is important
21-
dedupePlugin,
22-
sortPlugin,
23-
titleTemplatePlugin,
21+
DedupesTagsPlugin,
22+
SortTagsPlugin,
23+
TitleTemplatePlugin,
2424
]
2525
plugins.push(...(options.plugins || []))
2626
plugins.forEach(plugin => hooks.addHooks(plugin.hooks || {}))
2727

2828
const head: HeadClient<T> = {
29-
entries,
30-
_flushDomSideEffects() {
29+
_flushQueuedSideEffectFns() {
3130
Object.values(_sde).forEach(fn => fn())
3231
_sde = {}
3332
},
@@ -51,33 +50,33 @@ export function createHead<T extends {} = Head>(options: CreateHeadOptions<T> =
5150
if (e._i !== _i)
5251
return true
5352
// queue side effects
54-
_sde = {
55-
..._sde,
56-
...e._sde || {},
57-
}
53+
_sde = { ..._sde, ...e._sde || {} }
54+
e._sde = {}
5855
return false
5956
})
6057
},
6158
patch(input) {
6259
entries = entries.map((e) => {
60+
_sde = { ..._sde, ...e._sde || {} }
61+
e._sde = {}
6362
e.input = e._i === _i ? input : e.input
6463
return e
6564
})
6665
},
6766
}
6867
},
6968
async resolveTags() {
70-
await hooks.callHook('entries:resolve', head)
71-
const tags: HeadTag[] = entries.map(entry => normaliseEntryTags<T>(entry)).flat()
72-
for (const k in tags) {
73-
const tagCtx = { tag: tags[k], entry: entries.find(e => e._i === tags[k]._e)! }
74-
await hooks.callHook('tag:normalise', tagCtx)
75-
tags[k] = tagCtx.tag
69+
const resolveCtx: { tags: HeadTag[]; entries: HeadEntry<T>[] } = { tags: [], entries: [...entries] }
70+
await hooks.callHook('entries:resolve', resolveCtx)
71+
for (const entry of resolveCtx.entries) {
72+
for (const tag of normaliseEntryTags<T>(entry)) {
73+
const tagCtx = { tag, entry }
74+
await hooks.callHook('tag:normalise', tagCtx)
75+
resolveCtx.tags.push(tagCtx.tag)
76+
}
7677
}
77-
const ctx = { tags }
78-
await hooks.callHook('tags:beforeResolve', ctx)
79-
await hooks.callHook('tags:resolve', ctx)
80-
return ctx.tags
78+
await hooks.callHook('tags:resolve', resolveCtx)
79+
return resolveCtx.tags
8180
},
8281
}
8382

packages/unhead/src/normalise.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import type { Head, HeadTag } from '@unhead/schema'
22
import { ValidHeadTags, normaliseTag as normaliseTagBase } from 'zhead'
33
import type { HeadEntry } from './types'
4-
import { asArray } from './util'
4+
import { TagConfigKeys, asArray } from './util'
55

66
export function normaliseTag<T>(tagName: HeadTag['tag'], input: HeadTag['props'], entry: HeadEntry<T>): HeadTag | HeadTag[] {
77
const tag = normaliseTagBase(tagName, input, { childrenKeys: ['innerHTML', 'textContent'] }) as HeadTag
88
tag._e = entry._i
99

10-
// clear user tag options from the tag props (tagPosition, tagPriority, etc)
10+
// keys with direct mapping
1111
Object.keys(tag.props)
12-
.filter(k => k.startsWith('tag'))
12+
.filter(k => TagConfigKeys.includes(k))
1313
.forEach((k) => {
1414
// @ts-expect-error untyped
1515
tag[k] = tag.props[k]
@@ -26,9 +26,10 @@ export function normaliseTag<T>(tagName: HeadTag['tag'], input: HeadTag['props']
2626

2727
// allow meta to be resolved into multiple tags if an array is provided on content
2828
if (tag.props.content && Array.isArray(tag.props.content)) {
29-
return tag.props.content.map((v) => {
29+
return tag.props.content.map((v, i) => {
3030
const newTag = { ...tag, props: { ...tag.props } }
3131
newTag.props.content = v
32+
newTag.key = `${tag.props.name || tag.props.property}:${i}`
3233
return newTag
3334
})
3435
}
@@ -44,10 +45,7 @@ export function normaliseEntryTags<T extends {} = Head>(e: HeadEntry<T>) {
4445
)
4546
.flat(3)
4647
.map((t, i) => {
47-
// used to restore the order after deduping
48-
// a large number is needed otherwise the position will potentially duplicate (this support 10k tags)
49-
// ideally we'd use the total tag count but this is too hard to calculate with the current reactivity
50-
// << 8 is 256 tags per entry
48+
// support 256 tags per entry
5149
t._p = (e._i << 8) + (i++)
5250
return t
5351
})

packages/unhead/src/plugin/dedupePlugin.ts renamed to packages/unhead/src/plugin/dedupesTagsPlugin.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { tagDedupeKey } from 'zhead'
22
import type { HeadTag, HeadTagKeys } from '@unhead/schema'
3-
import { defineHeadPlugin } from './defineHeadPlugin'
3+
import { defineHeadPlugin } from '.'
44

5-
export const dedupePlugin = defineHeadPlugin({
5+
export const DedupesTagsPlugin = defineHeadPlugin({
66
hooks: {
77
'tag:normalise': function ({ tag }) {
8-
// dedupe keys
8+
// support for third-party dedupe keys
99
(<HeadTagKeys> ['hid', 'vmid', 'key']).forEach((key) => {
1010
if (tag.props[key]) {
1111
tag.key = tag.props[key]
@@ -22,7 +22,9 @@ export const dedupePlugin = defineHeadPlugin({
2222
ctx.tags.forEach((tag, i) => {
2323
let dedupeKey = tag._d || tag._p || i
2424
const dupedTag = deduping[dedupeKey]
25+
// handling a duplicate tag
2526
if (dupedTag) {
27+
// default strategy is replace, unless we're dealing with a html or body attrs
2628
let strategy = tag?.tagDuplicateStrategy
2729
if (!strategy && (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs'))
2830
strategy = 'merge'

packages/unhead/src/plugin/hydratesStatePlugin.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { HasElementTags } from 'zhead'
22
import { hashCode } from '../util'
33
import { defineHeadPlugin } from '.'
44

5-
export const hydratesStatePlugin = defineHeadPlugin({
5+
export const HydratesStatePlugin = defineHeadPlugin({
66
hooks: {
77
'tag:normalise': (ctx) => {
88
const { tag, entry } = ctx
@@ -11,11 +11,10 @@ export const hydratesStatePlugin = defineHeadPlugin({
1111
return
1212
// if we're rendering server side, root meta which will not be removed (only updated) and the meta
1313
// does not generate dupes (i.e is not a meta tag) then we can skip hydration
14-
if (typeof tag._d === 'undefined' && entry.mode === 'server')
14+
if (typeof tag._d === 'undefined' && entry._m === 'server')
1515
return
1616

17-
// need to get a hashed string version of _d
18-
// do a simple md5 of the _s
17+
// _s is the hydrate state key, it's a light-weight hash which may have conflicts
1918
tag._s = `data-h-${hashCode(tag._d || (tag.tag + JSON.stringify(tag.props)))}`
2019
tag.props[tag._s] = ''
2120
},
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export * from './defineHeadPlugin'
2-
export * from './dedupePlugin'
3-
export * from './sortPlugin'
2+
export * from './dedupesTagsPlugin'
3+
export * from './sortTagsPlugin'
44
export * from './titleTemplatePlugin'
55
export * from './hydratesStatePlugin'

packages/unhead/src/plugin/sanitiseInputPlugin.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

packages/unhead/src/plugin/sortPlugin.ts renamed to packages/unhead/src/plugin/sortTagsPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { sortCriticalTags } from 'zhead'
22
import { defineHeadPlugin } from '.'
33

4-
export const sortPlugin = defineHeadPlugin({
4+
export const SortTagsPlugin = defineHeadPlugin({
55
hooks: {
66
'tags:resolve': (ctx) => {
77
const tagIndexForKey = (key: string) => ctx.tags.find(tag => tag._d === key)?._p

packages/unhead/src/plugin/titleTemplatePlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { resolveTitleTemplateFromTags } from 'zhead'
22
import { defineHeadPlugin } from '.'
33

4-
export const titleTemplatePlugin = defineHeadPlugin({
4+
export const TitleTemplatePlugin = defineHeadPlugin({
55
hooks: {
66
'tags:resolve': (ctx) => {
77
ctx.tags = resolveTitleTemplateFromTags(ctx.tags)

packages/unhead/src/runtime/client/renderDOMHead.ts

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,89 @@
1-
import { createElement } from 'zhead'
2-
import type { DomRenderTagContext, HeadClient, SideEffectsRecord } from '../../types'
3-
import { setAttributes } from './setAttributes'
4-
5-
let domUpdatePromise: Promise<void> | null = null
1+
import { TagsWithInnerContent, createElement } from 'zhead'
2+
import type { DomRenderTagContext, HeadClient } from '../../types'
3+
import { setAttributesWithSideEffects } from './setAttributesWithSideEffects'
64

75
export interface RenderDomHeadOptions {
6+
/**
7+
* Document to use for rendering. Allows stubbing for testing.
8+
*/
89
document?: Document
910
}
1011

11-
export const renderDOMHead = async<T extends HeadClient<any>>(head: T, options: RenderDomHeadOptions = {}) => {
12+
/**
13+
* Render the head tags to the DOM.
14+
*/
15+
export async function renderDOMHead<T extends HeadClient<any>>(head: T, options: RenderDomHeadOptions = {}) {
1216
const dom: Document = options.document || window.document
1317

1418
const tags = await head.resolveTags()
1519

1620
await head.hooks.callHook('dom:beforeRender', { head, tags, document: dom })
1721

18-
// start with a clean slate
19-
head._flushDomSideEffects()
22+
// remove
23+
head._flushQueuedSideEffectFns()
2024

21-
const sideEffectMap: Record<number, SideEffectsRecord> = {}
2225
// default is to only create tags, not to resolve state
2326
for (const tag of tags) {
24-
sideEffectMap[tag._e!] = sideEffectMap[tag._e!] || {}
27+
const entry = head.headEntries().find(e => e._i === Number(tag._e))!
28+
const sdeKey = `${tag._s || tag._p}:el`
2529
// if we can hydrate an element via the selector id, do that instead of creating a new one
26-
let $el = tag._s ? dom.querySelector(`[${tag._s}]`) : null
27-
const renderCtx: DomRenderTagContext = { tag, document: dom, $el, head }
30+
// creating element with side effects
31+
const $newEl = createElement(tag, dom)
32+
const $el = tag._s ? dom.querySelector(`[${tag._s}]`) : null
33+
const renderCtx: DomRenderTagContext = { tag, document: dom, head }
2834
await head.hooks.callHook('dom:renderTag', renderCtx)
2935
// updating an existing tag
3036
if ($el) {
3137
if (Object.keys(tag.props).length === 0) {
3238
$el.remove()
3339
continue
3440
}
35-
sideEffectMap[tag._e!] = {
36-
...sideEffectMap[tag._e!],
37-
...setAttributes($el, tag),
38-
}
39-
$el.innerHTML = tag.children || ''
40-
sideEffectMap[tag._e!][`${tag._p}:el:remove`] = () => $el?.remove()
41+
setAttributesWithSideEffects($el, entry, tag)
42+
if (TagsWithInnerContent.includes(tag.tag))
43+
$el.innerHTML = tag.children || ''
44+
45+
// may be a duplicate but it's okay
46+
entry._sde[sdeKey] = () => $el?.remove()
4147
continue
4248
}
4349

4450
if (tag.tag === 'title' && tag.children) {
51+
// we don't handle title side effects
4552
dom.title = tag.children
4653
continue
4754
}
4855

4956
if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs') {
50-
sideEffectMap[tag._e!] = {
51-
...sideEffectMap[tag._e!],
52-
...setAttributes(dom[tag.tag === 'htmlAttrs' ? 'documentElement' : 'body'], tag),
53-
}
57+
setAttributesWithSideEffects(dom[tag.tag === 'htmlAttrs' ? 'documentElement' : 'body'], entry, tag)
5458
continue
5559
}
5660

57-
$el = createElement(tag, dom)
58-
5961
switch (tag.tagPosition) {
6062
case 'bodyClose':
61-
dom.body.appendChild($el)
63+
dom.body.appendChild($newEl)
6264
break
6365
case 'bodyOpen':
64-
dom.body.insertBefore($el, dom.body.firstChild)
66+
dom.body.insertBefore($newEl, dom.body.firstChild)
6567
break
6668
case 'head':
6769
default:
68-
dom.head.appendChild($el)
70+
dom.head.appendChild($newEl)
6971
break
7072
}
7173

72-
sideEffectMap[tag._e!][`${tag._p}:el:remove`] = () => $el?.remove()
73-
}
74-
75-
// add side effects once we've rendered
76-
for (const k in sideEffectMap) {
77-
const entry = head.headEntries().find(e => e._i === Number(k))!
78-
entry._sde = {
79-
...entry._sde,
80-
...sideEffectMap[k],
81-
}
74+
entry._sde[sdeKey] = () => $newEl?.remove()
8275
}
8376
}
8477

85-
export const debouncedUpdateDom = async<T extends HeadClient<any>>(delayedFn: (fn: () => void) => void, head: T, options: RenderDomHeadOptions = {}) => {
78+
/**
79+
* Global instance of the dom update promise. Used for debounding head updates.
80+
*/
81+
export let domUpdatePromise: Promise<void> | null = null
82+
83+
/**
84+
* Queue a debounced update of the DOM head.
85+
*/
86+
export async function debouncedRenderDOMHead<T extends HeadClient<any>>(delayedFn: (fn: () => void) => void, head: T, options: RenderDomHeadOptions = {}) {
8687
// within the debounced dom update we need to compute all the tags so that watchEffects still works
8788
function doDomUpdate() {
8889
domUpdatePromise = null

0 commit comments

Comments
 (0)