Skip to content

Commit b98cbd5

Browse files
committed
feat(dom): improved side effect handling
1 parent 4cd7073 commit b98cbd5

File tree

6 files changed

+85
-98
lines changed

6 files changed

+85
-98
lines changed

packages/dom/src/renderDOMHead.ts

Lines changed: 80 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { TagsWithInnerContent, createElement } from 'zhead'
2-
import type { BeforeRenderContext, DomRenderTagContext, Unhead } from '@unhead/schema'
3-
import { setAttributesWithSideEffects } from './setAttributesWithSideEffects'
2+
import type { BeforeRenderContext, DomRenderTagContext, HeadEntry, HeadTag, Unhead } from '@unhead/schema'
43

54
export interface RenderDomHeadOptions {
65
/**
@@ -14,82 +13,126 @@ export interface RenderDomHeadOptions {
1413
*/
1514
export async function renderDOMHead<T extends Unhead<any>>(head: T, options: RenderDomHeadOptions = {}) {
1615
const dom: Document = options.document || window.document
17-
1816
const tags = await head.resolveTags()
1917

20-
const context: BeforeRenderContext = { shouldRender: true, tags }
21-
await head.hooks.callHook('dom:beforeRender', context)
18+
const ctx: BeforeRenderContext = { shouldRender: true, tags }
19+
await head.hooks.callHook('dom:beforeRender', ctx)
2220
// allow integrations to block to the render
23-
if (!context.shouldRender)
21+
if (!ctx.shouldRender)
2422
return
2523

26-
for (const tag of context.tags) {
27-
const renderCtx: DomRenderTagContext = { shouldRender: true, tag }
28-
await head.hooks.callHook('dom:renderTag', renderCtx)
29-
if (!renderCtx.shouldRender)
30-
continue
31-
32-
const entry = head.headEntries().find(e => e._i === Number(tag._e))!
33-
24+
// queue everything to be deleted, and then we'll conditionally remove side effects which we don't want to fire
25+
const queuedSideEffects = head._popSideEffectQueue()
26+
head.headEntries()
27+
.map(entry => entry._sde)
28+
.forEach((sde) => {
29+
Object.entries(sde).forEach(([key, fn]) => {
30+
queuedSideEffects[key] = fn
31+
})
32+
})
33+
34+
const renderTag = (tag: HeadTag, entry: HeadEntry<any>) => {
3435
if (tag.tag === 'title' && tag.children) {
3536
// we don't handle title side effects
3637
dom.title = tag.children
37-
continue
38+
return
39+
}
40+
41+
const markSideEffect = (key: string, fn: () => void) => {
42+
key = `${tag._s || tag._p}:${key}`
43+
entry._sde[key] = fn
44+
delete queuedSideEffects[key]
45+
}
46+
47+
/**
48+
* Set attributes on a DOM element, while adding entry side effects.
49+
*/
50+
const setAttrs = ($el: Element) => {
51+
// add new attributes
52+
Object.entries(tag.props).forEach(([k, value]) => {
53+
value = String(value)
54+
const attrSdeKey = `attr:${k}`
55+
// class attributes have their own side effects to allow for merging
56+
if (k === 'class') {
57+
for (const c of value.split(' ')) {
58+
const classSdeKey = `${attrSdeKey}:${c}`
59+
// always clear side effects
60+
markSideEffect(classSdeKey, () => $el.classList.remove(c))
61+
62+
if (!$el.classList.contains(c))
63+
$el.classList.add(c)
64+
}
65+
return
66+
}
67+
// always clear side effects
68+
if (!k.startsWith('data-h-'))
69+
markSideEffect(attrSdeKey, () => $el.removeAttribute(k))
70+
71+
if ($el.getAttribute(k) !== value)
72+
$el.setAttribute(k, value)
73+
})
3874
}
3975

4076
if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs') {
41-
setAttributesWithSideEffects(head, dom[tag.tag === 'htmlAttrs' ? 'documentElement' : 'body'], entry, tag)
42-
continue
77+
setAttrs(dom[tag.tag === 'htmlAttrs' ? 'documentElement' : 'body'])
78+
return
4379
}
4480

45-
const sdeKey = `${tag._s || tag._p}:el`
46-
const $newEl = createElement(tag, dom)
47-
let $previousEl: Element | null = null
81+
let $newEl = createElement(tag, dom)
82+
let $previousEl: Element | undefined
4883
// optimised scan of children
4984
for (const $el of dom[tag.tagPosition?.startsWith('body') ? 'body' : 'head'].children) {
50-
if ($el.hasAttribute(`${tag._s}`)) {
85+
if ($el.hasAttribute(`${tag._s}`) || $el.isEqualNode($newEl)) {
5186
$previousEl = $el
5287
break
5388
}
5489
}
5590

5691
// updating an existing tag
5792
if ($previousEl) {
58-
// safe to ignore removal
59-
head._removeQueuedSideEffect(sdeKey)
93+
markSideEffect('el', () => $previousEl?.remove())
6094

61-
if ($newEl.isEqualNode($previousEl))
62-
continue
95+
// @todo test around empty tags
6396
if (Object.keys(tag.props).length === 0) {
6497
$previousEl.remove()
65-
continue
98+
return
6699
}
67-
setAttributesWithSideEffects(head, $previousEl, entry, tag)
100+
if ($newEl.isEqualNode($previousEl))
101+
return
102+
103+
setAttrs($previousEl)
68104
if (TagsWithInnerContent.includes(tag.tag))
69105
$previousEl.innerHTML = tag.children || ''
70-
71-
// may be a duplicate but it's okay
72-
entry._sde[sdeKey] = () => $previousEl?.remove()
73-
continue
106+
return
74107
}
75108

76109
switch (tag.tagPosition) {
77110
case 'bodyClose':
78-
dom.body.appendChild($newEl)
111+
$newEl = dom.body.appendChild($newEl)
79112
break
80113
case 'bodyOpen':
81-
dom.body.insertBefore($newEl, dom.body.firstChild)
114+
$newEl = dom.body.insertBefore($newEl, dom.body.firstChild)
82115
break
83116
case 'head':
84117
default:
85-
dom.head.appendChild($newEl)
118+
$newEl = dom.head.appendChild($newEl)
86119
break
87120
}
88-
entry._sde[sdeKey] = () => $newEl?.remove()
121+
122+
markSideEffect('el', () => $newEl?.remove())
123+
}
124+
125+
for (const tag of ctx.tags) {
126+
const renderCtx: DomRenderTagContext = { shouldRender: true, tag }
127+
await head.hooks.callHook('dom:renderTag', renderCtx)
128+
if (!renderCtx.shouldRender)
129+
continue
130+
131+
renderTag(tag, head.headEntries().find(e => e._i === Number(tag._e))!)
89132
}
90133

91-
// run side effect cleanup
92-
head._flushQueuedSideEffects()
134+
// clear all side effects still pending
135+
Object.values(queuedSideEffects).forEach(fn => fn())
93136
}
94137

95138
/**

packages/dom/src/setAttributesWithSideEffects.ts

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

packages/schema/src/head.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,6 @@ export interface Unhead<Input extends {} = Head> {
8888
/**
8989
* @internal
9090
*/
91-
_removeQueuedSideEffect: (key: string) => void
92-
/**
93-
* @internal
94-
*/
95-
_flushQueuedSideEffects: () => void
91+
_popSideEffectQueue: () => SideEffectsRecord
9692
}
9793

packages/unhead/src/createHead.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@ export function createHead<T extends {} = Head>(options: CreateHeadOptions = {})
2828
const triggerUpdate = () => hooks.callHook('entries:updated', head)
2929

3030
const head: Unhead<T> = {
31-
_removeQueuedSideEffect(key) {
32-
delete _sde[key]
33-
},
34-
_flushQueuedSideEffects() {
35-
Object.values(_sde).forEach(fn => fn())
31+
_popSideEffectQueue() {
32+
const sde = { ..._sde }
3633
_sde = {}
34+
return sde
3735
},
3836
headEntries() {
3937
return entries

packages/unhead/src/plugin/dedupesTagsPlugin.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ export const DedupesTagsPlugin = (options?: DedupesTagsPluginOptions) => {
6060
}
6161
tag._d = dedupeKey
6262
}
63-
else {
64-
// insert the tag change in the original spot
65-
tag._p = dupedTag._p
66-
}
6763
// if the new tag does not have any props we're trying to remove the dupedTag
6864
if (Object.keys(tag.props).length === 0 && !tag.children) {
6965
delete deduping[dedupeKey]

packages/unhead/src/plugin/patchDomOnEntryUpdatesPlugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { RenderDomHeadOptions } from '@unhead/dom'
2-
import { defineHeadPlugin } from '../defineHeadPlugin'
2+
import { defineHeadPlugin } from '..'
33

44
interface TriggerDomPatchingOnUpdatesPluginOptions extends RenderDomHeadOptions {
55
delayFn?: (fn: () => void) => void

0 commit comments

Comments
 (0)