Skip to content

Commit

Permalink
perf: isolate plugin logic (#451)
Browse files Browse the repository at this point in the history
* feat: vanilla function resolves

Fixes #435

* chore: broken test

* perf: isolate plugin logic
  • Loading branch information
harlan-zw authored Jan 8, 2025
1 parent b2ed420 commit 66a4661
Show file tree
Hide file tree
Showing 17 changed files with 133 additions and 91 deletions.
6 changes: 3 additions & 3 deletions packages/addons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@
"stub": "unbuild . --stub",
"export:sizes": "npx export-size . -r"
},
"peerDependencies": {
"unhead": "workspace:*"
},
"dependencies": {
"@rollup/pluginutils": "^5.1.4",
"@unhead/schema": "workspace:*",
Expand All @@ -64,9 +67,6 @@
"unplugin": "^2.1.2",
"unplugin-ast": "^0.13.1"
},
"peerDependencies": {
"unhead": "workspace:*"
},
"devDependencies": {
"@babel/types": "^7.26.3"
}
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const SelfClosingTags = new Set(['meta', 'link', 'base'])
export const DupeableTags = new Set(['link', 'style', 'script', 'noscript'])
export const TagsWithInnerContent = new Set(['title', 'titleTemplate', 'script', 'style', 'noscript'])
export const HasElementTags = new Set([
'base',
Expand All @@ -24,7 +25,7 @@ export const ValidHeadTags = new Set([

export const UniqueTags = new Set(['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams'])

export const TagConfigKeys = new Set(['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'innerHTML', 'textContent', 'processTemplateParams'])
export const TagConfigKeys = new Set(['key', 'tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'innerHTML', 'textContent', 'processTemplateParams'])

export const IsBrowser = typeof window !== 'undefined'

Expand Down
17 changes: 16 additions & 1 deletion packages/shared/src/normalise.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Head, HeadEntry, HeadTag } from '@unhead/schema'
import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from './constants'
import { DupeableTags, TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from './constants'
import { hashCode } from './hashCode'
import { tagDedupeKey } from './tagDedupeKey'

export function normaliseTag<T extends HeadTag>(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry<T>, normalizedProps?: HeadTag['props']): T | T[] {
const props = normalizedProps || normaliseProps<T>(
Expand Down Expand Up @@ -27,6 +29,19 @@ export function normaliseTag<T extends HeadTag>(tagName: T['tag'], input: HeadTa
delete tag.props[k]
}
}
// only if the user has provided a key
// only tags which can't dedupe themselves, ssr only
if (tag.key && DupeableTags.has(tag.tag)) {
// add a HTML key so the client-side can hydrate without causing duplicates
tag.props['data-hid'] = tag._h = hashCode(tag.key!)
}
const generatedKey = tagDedupeKey(tag)
if (generatedKey && !generatedKey.startsWith('meta:og:') && !generatedKey.startsWith('meta:twitter:')) {
delete tag.key
}
const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false)
if (dedupe)
tag._d = dedupe
// shorthand for objects
if (tag.tag === 'script') {
if (typeof tag.innerHTML === 'object') {
Expand Down
14 changes: 8 additions & 6 deletions packages/unhead/src/client/createHead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import type { CreateHeadOptions, Head } from '@unhead/schema'
import { IsBrowser } from '@unhead/shared'
import { unheadCtx } from '../context'
import { createHeadCore } from '../createHead'
import { DomPlugin } from './domPlugin'
import { DomPlugin } from './plugins/domPlugin'
import { ClientEventHandlerPlugin } from './plugins/eventHandlers'

export function createHead<T extends Record<string, any> = Head>(options: CreateHeadOptions = {}) {
const head = createHeadCore<T>({
document: (IsBrowser ? document : undefined),
...options,
plugins: [
...(options.plugins || []),
DomPlugin(),
ClientEventHandlerPlugin,
],
})
head.use(DomPlugin())
// should only be one instance client-side
if (!head.ssr && IsBrowser) {
unheadCtx.set(head, true)
}
unheadCtx.set(head, true)
return head
}
2 changes: 1 addition & 1 deletion packages/unhead/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * from './createHead'
export * from './debounced'
export * from './domPlugin'
export * from './plugins/domPlugin'
export * from './renderDOMHead'
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { RenderDomHeadOptions } from './renderDOMHead'
import type { RenderDomHeadOptions } from '../renderDOMHead'
import { defineHeadPlugin } from '@unhead/shared'
import { debouncedRenderDOMHead } from './debounced'
import { debouncedRenderDOMHead } from '../debounced'

export interface DomPluginOptions extends RenderDomHeadOptions {
delayFn?: (fn: () => void) => void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineHeadPlugin, hashCode, NetworkEvents } from '@unhead/shared'
import { defineHeadPlugin, NetworkEvents } from '@unhead/shared'

const ValidEventTags = new Set(['script', 'link', 'bodyAttrs'])

Expand All @@ -7,7 +7,7 @@ const ValidEventTags = new Set(['script', 'link', 'bodyAttrs'])
*
* When SSR we need to strip out these values. On CSR we
*/
export default defineHeadPlugin(head => ({
export const ClientEventHandlerPlugin = defineHeadPlugin({
hooks: {
'tags:resolve': (ctx) => {
for (const tag of ctx.tags) {
Expand All @@ -33,21 +33,10 @@ export default defineHeadPlugin(head => ({
continue
}

// insert a inline script to set the status of onload and onerror
if (head.ssr && NetworkEvents.has(key)) {
props[key] = `this.dataset.${key}fired = true`
}
else {
delete props[key]
}

delete props[key]
tag._eventHandlers = tag._eventHandlers || {}
tag._eventHandlers![key] = value
}

if (head.ssr && tag._eventHandlers && (tag.props.src || tag.props.href)) {
tag.key = tag.key || hashCode(tag.props.src || tag.props.href)
}
}
},
'dom:renderTag': ({ $el, tag }) => {
Expand Down Expand Up @@ -75,4 +64,4 @@ export default defineHeadPlugin(head => ({
}
},
},
}))
})
4 changes: 0 additions & 4 deletions packages/unhead/src/createHead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import type {
import { normaliseEntryTags } from '@unhead/shared'
import { createHooks } from 'hookable'
import DedupePlugin from './plugins/dedupe'
import EventHandlersPlugin from './plugins/eventHandlers'
import HashKeyedPlugin from './plugins/hashKeyed'
import SortPlugin from './plugins/sort'
import TemplateParamsPlugin from './plugins/templateParams'
import TitleTemplatePlugin from './plugins/titleTemplate'
Expand Down Expand Up @@ -114,8 +112,6 @@ export function createHeadCore<T extends Record<string, any> = Head>(options: Cr
}
;[
DedupePlugin,
EventHandlersPlugin,
HashKeyedPlugin,
SortPlugin,
TemplateParamsPlugin,
TitleTemplatePlugin,
Expand Down
35 changes: 25 additions & 10 deletions packages/unhead/src/legacy.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import type { CreateHeadOptions, Head } from '@unhead/schema'
import { IsBrowser } from '@unhead/shared'
import { DomPlugin } from './client/domPlugin'
import { DomPlugin } from './client/plugins/domPlugin'
import { ClientEventHandlerPlugin } from './client/plugins/eventHandlers'
import { unheadCtx } from './context'
import { createHeadCore } from './createHead'
import { DeprecationsPlugin } from './optionalPlugins/deprecations'
import { PromisesPlugin } from './optionalPlugins/promises'
import { ServerEventHandlerPlugin } from './server/plugins/eventHandlers'
import { PayloadPlugin } from './server/plugins/payload'

export function createServerHead<T extends Record<string, any> = Head>(options: CreateHeadOptions = {}) {
// @ts-expect-error untyped
const head = createHeadCore<T>({ disableCapoSorting: true, ...options, document: false })
head.use(DeprecationsPlugin)
head.use(PromisesPlugin)
return head
return createHeadCore<T>({
disableCapoSorting: true,
...options,
// @ts-expect-error untyped
document: false,
plugins: [
...(options.plugins || []),
DomPlugin(),
DeprecationsPlugin,
PromisesPlugin,
ServerEventHandlerPlugin,
PayloadPlugin,
],
})
}

export function createHead<T extends Record<string, any> = Head>(options: CreateHeadOptions = {}) {
const head = createHeadCore<T>({ disableCapoSorting: true, ...options })
head.use(DomPlugin())
head.use(DeprecationsPlugin)
head.use(PromisesPlugin)
const head = createHeadCore<T>({ disableCapoSorting: true, ...options, plugins: [
...(options.plugins || []),
DomPlugin(),
DeprecationsPlugin,
PromisesPlugin,
ClientEventHandlerPlugin,
] })
// should only be one instance client-side
if (!head.ssr && IsBrowser) {
unheadCtx.set(head, true)
Expand Down
15 changes: 1 addition & 14 deletions packages/unhead/src/plugins/dedupe.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,10 @@
import type { HeadTag } from '@unhead/schema'
import { defineHeadPlugin, HasElementTags, hashTag, tagDedupeKey, tagWeight } from '@unhead/shared'
import { defineHeadPlugin, HasElementTags, hashTag, tagWeight } from '@unhead/shared'

const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs'])

export default defineHeadPlugin(head => ({
hooks: {
'tag:normalise': ({ tag }) => {
if (tag.props.key) {
tag.key = tag.props.key
delete tag.props.key
}
const generatedKey = tagDedupeKey(tag)
if (generatedKey && !generatedKey.startsWith('meta:og:') && !generatedKey.startsWith('meta:twitter:')) {
delete tag.key
}
const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false)
if (dedupe)
tag._d = dedupe
},
'tags:resolve': (ctx) => {
// 1. Dedupe tags
const deduping: Record<string, HeadTag> = Object.create(null)
Expand Down
16 changes: 0 additions & 16 deletions packages/unhead/src/plugins/hashKeyed.ts

This file was deleted.

7 changes: 2 additions & 5 deletions packages/unhead/src/plugins/sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,8 @@ export default defineHeadPlugin(head => ({
const bWeight = tagWeight(head, b)

// 2c. sort based on critical tags
if (aWeight < bWeight) {
return -1
}
else if (aWeight > bWeight) {
return 1
if (aWeight !== bWeight) {
return aWeight - bWeight
}

// 2b. sort tags in their natural order
Expand Down
17 changes: 12 additions & 5 deletions packages/unhead/src/server/createHead.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import type { CreateHeadOptions, Head } from '@unhead/schema'
import { createHeadCore } from '../createHead'
import PayloadPlugin from './plugins/payload'
import { ServerEventHandlerPlugin } from './plugins/eventHandlers'
import { PayloadPlugin } from './plugins/payload'

export function createHead<T extends Record<string, any> = Head>(options: CreateHeadOptions = {}) {
// @ts-expect-error untyped
const head = createHeadCore<T>({ ...options, document: false })
head.use(PayloadPlugin)
return head
return createHeadCore<T>({
...options,
// @ts-expect-error untyped
document: false,
plugins: [
...(options.plugins || []),
PayloadPlugin,
ServerEventHandlerPlugin,
],
})
}
50 changes: 50 additions & 0 deletions packages/unhead/src/server/plugins/eventHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { defineHeadPlugin, hashCode, NetworkEvents } from '@unhead/shared'

const ValidEventTags = new Set(['script', 'link', 'bodyAttrs'])

/**
* Supports DOM event handlers (i.e `onload`) as functions.
*
* When SSR we need to strip out these values. On CSR we
*/
export const ServerEventHandlerPlugin = defineHeadPlugin({
hooks: {
'tags:resolve': (ctx) => {
for (const tag of ctx.tags) {
if (!ValidEventTags.has(tag.tag)) {
continue
}

const props = tag.props

let hasEventHandlers = false
for (const key in props) {
// on
if (key[0] !== 'o' || key[1] !== 'n') {
continue
}

if (!Object.prototype.hasOwnProperty.call(props, key)) {
continue
}

const value = props[key]

if (typeof value !== 'function') {
continue
}

// insert a inline script to set the status of onload and onerror
if (NetworkEvents.has(key)) {
props[key] = `this.dataset.${key}fired = true`
hasEventHandlers = true
}
}

if (hasEventHandlers && (tag.props.src || tag.props.href)) {
tag.key = tag.key || hashCode(tag.props.src || tag.props.href)
}
}
},
},
})
3 changes: 1 addition & 2 deletions packages/unhead/src/server/plugins/payload.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { defineHeadPlugin } from '@unhead/shared'

export default defineHeadPlugin({
mode: 'server',
export const PayloadPlugin = defineHeadPlugin({
hooks: {
'tags:beforeResolve': (ctx) => {
const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record<string, string>, title?: string } = {}
Expand Down
4 changes: 2 additions & 2 deletions test/unhead/ssr/eventHandlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { renderSSRHead } from '@unhead/ssr'
import { useHead } from 'unhead'
import { describe, it } from 'vitest'
import { createHeadWithContext } from '../../util'
import { createServerHeadWithContext } from '../../util'

describe('ssr event handlers', () => {
it('basic', async () => {
const head = createHeadWithContext()
const head = createServerHeadWithContext()

useHead({
script: [
Expand Down
Loading

0 comments on commit 66a4661

Please sign in to comment.