From 4e2f2787cc1a922e19ffe9aaeab936014e938e8d Mon Sep 17 00:00:00 2001 From: Negezor Date: Sat, 13 Jul 2024 01:51:31 +1100 Subject: [PATCH 01/78] =?UTF-8?q?perf:=20compare=20values=20=E2=80=8B?= =?UTF-8?q?=E2=80=8Bdirectly=20instead=20of=20includes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/schema-org/src/vue/runtime/components.ts | 2 +- packages/shared/src/normalise.ts | 10 +++++----- packages/shared/src/safe.ts | 2 +- packages/shared/src/templateParams.ts | 4 +++- packages/unhead/src/optionalPlugins/capoPlugin.ts | 4 ++-- packages/unhead/src/plugins/payload.ts | 2 +- packages/unhead/src/plugins/templateParams.ts | 2 +- packages/unhead/src/plugins/xss.ts | 8 ++++---- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/schema-org/src/vue/runtime/components.ts b/packages/schema-org/src/vue/runtime/components.ts index bb442484..a97771aa 100644 --- a/packages/schema-org/src/vue/runtime/components.ts +++ b/packages/schema-org/src/vue/runtime/components.ts @@ -49,7 +49,7 @@ function ignoreKey(s: string) { if (s.startsWith('aria-') || s.startsWith('data-')) return false - return ['class', 'style'].includes(s) + return s === 'class' || s === 'style' } export function defineSchemaOrgComponent(name: string, defineFn: (input: any) => any): ReturnType { diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 92b09fe2..b9b1e4d2 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -10,8 +10,8 @@ export async function normaliseTag(tagName: T['tag'], input: // @ts-expect-error untyped typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) ? { ...input } - : { [['script', 'noscript', 'style'].includes(tagName) ? 'innerHTML' : 'textContent']: input }, - ['templateParams', 'titleTemplate'].includes(tagName), + : { [(tagName === 'script' || tagName === 'noscript' || tagName === 'style') ? 'innerHTML' : 'textContent']: input }, + (tagName === 'templateParams' || tagName === 'titleTemplate'), ), } as T // merge options from the entry @@ -19,8 +19,8 @@ export async function normaliseTag(tagName: T['tag'], input: // @ts-expect-error untyped const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] if (typeof val !== 'undefined') { - // strip innerHTML and textContent for tags which don't support it - if (!['innerHTML', 'textContent', 'children'].includes(k) || TagsWithInnerContent.includes(tag.tag)) { + // strip innerHTML and textContent for tags which don't support it= + if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.includes(tag.tag)) { // @ts-expect-error untyped tag[k === 'children' ? 'innerHTML' : k] = val } @@ -66,7 +66,7 @@ export async function normaliseProps(props: T['props'], virtu // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes for (const k of Object.keys(props)) { // class has special handling - if (['class', 'style'].includes(k)) { + if (k === 'class' || k === 'style') { // @ts-expect-error untyped props[k] = normaliseStyleClassProps(k, props[k]) continue diff --git a/packages/shared/src/safe.ts b/packages/shared/src/safe.ts index 877887a9..a294c004 100644 --- a/packages/shared/src/safe.ts +++ b/packages/shared/src/safe.ts @@ -69,7 +69,7 @@ export function whitelistSafeInput(input: Record { const val = meta[key] // block bad rel types - if (key === 'rel' && ['stylesheet', 'canonical', 'modulepreload', 'prerender', 'preload', 'prefetch'].includes(val)) + if (key === 'rel' && (val === 'stylesheet' || val === 'canonical' || val === 'modulepreload' || val === 'prerender' || val === 'preload' || val === 'prefetch')) return if (key === 'href') { diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index 73c95347..05980d7a 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -9,7 +9,9 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) // for each % token replace it with the corresponding runtime config or an empty value function sub(token: string) { let val: string | undefined - if (['s', 'pageTitle'].includes(token)) { val = p.pageTitle as string } + if (token === 's' || token === 'pageTitle') { + val = p.pageTitle as string + } // support . notation else if (token.includes('.')) { // @ts-expect-error untyped diff --git a/packages/unhead/src/optionalPlugins/capoPlugin.ts b/packages/unhead/src/optionalPlugins/capoPlugin.ts index 9f7a4e50..7c26a752 100644 --- a/packages/unhead/src/optionalPlugins/capoPlugin.ts +++ b/packages/unhead/src/optionalPlugins/capoPlugin.ts @@ -35,7 +35,7 @@ const importRe = /@import/ // SYNC_STYLES tag.tagPriority = 60 } - else if (isLink && ['preload', 'modulepreload'].includes(tag.props.rel)) { + else if (isLink && (tag.props.rel === 'preload' || tag.props.rel === 'modulepreload')) { // PRELOAD tag.tagPriority = 70 } @@ -43,7 +43,7 @@ const importRe = /@import/ // DEFER_SCRIPT tag.tagPriority = 80 } - else if (isLink && ['prefetch', 'dns-prefetch', 'prerender'].includes(tag.props.rel)) { + else if (isLink && (tag.props.rel === 'prefetch' || tag.props.rel === 'dns-prefetch' || tag.props.rel === 'prerender')) { tag.tagPriority = 90 } } diff --git a/packages/unhead/src/plugins/payload.ts b/packages/unhead/src/plugins/payload.ts index 26507503..1d6e9dea 100644 --- a/packages/unhead/src/plugins/payload.ts +++ b/packages/unhead/src/plugins/payload.ts @@ -5,7 +5,7 @@ export default defineHeadPlugin({ hooks: { 'tags:resolve': function (ctx) { const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record, title?: string } = {} - ctx.tags.filter(tag => ['titleTemplate', 'templateParams', 'title'].includes(tag.tag) && tag._m === 'server') + ctx.tags.filter(tag => (tag.tag === 'titleTemplate' || tag.tag === 'templateParams' || tag.tag === 'title') && tag._m === 'server') .forEach((tag) => { // @ts-expect-error untyped payload[tag.tag] = tag.tag.startsWith('title') ? tag.textContent : tag.props diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index 224b68a7..11c431d1 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -28,7 +28,7 @@ export default defineHeadPlugin(head => ({ tag.props[v] = processTemplateParams(tag.props[v], params, sep) } // everything else requires explicit opt-in - else if (tag.processTemplateParams === true || ['titleTemplate', 'title'].includes(tag.tag)) { + else if (tag.processTemplateParams === true || tag.tag === 'titleTemplate' || tag.tag === 'title') { ['innerHTML', 'textContent'].forEach((p) => { // @ts-expect-error untyped if (typeof tag[p] === 'string') diff --git a/packages/unhead/src/plugins/xss.ts b/packages/unhead/src/plugins/xss.ts index 57c32600..0892aa39 100644 --- a/packages/unhead/src/plugins/xss.ts +++ b/packages/unhead/src/plugins/xss.ts @@ -5,12 +5,12 @@ export default defineHeadPlugin({ 'tags:afterResolve': function (ctx) { for (const tag of ctx.tags) { if (typeof tag.innerHTML === 'string') { - if (tag.innerHTML && ['application/ld+json', 'application/json'].includes(tag.props.type)) + if (tag.innerHTML && (tag.props.type === 'application/ld+json' || tag.props.type === 'application/json')) { // ensure tags get encoded, this is only for JSON, it will break HTML if used - { tag.innerHTML = tag.innerHTML.replace(/ Date: Sat, 13 Jul 2024 01:54:13 +1100 Subject: [PATCH 02/78] perf: compare first character via access index instead of startsWith --- packages/schema-org/src/core/resolve.ts | 2 +- packages/schema-org/src/core/util.ts | 2 +- packages/schema-org/src/utils/index.ts | 4 ++-- packages/vue/src/env.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/schema-org/src/core/resolve.ts b/packages/schema-org/src/core/resolve.ts index 2a603e55..b2e6a7cc 100644 --- a/packages/schema-org/src/core/resolve.ts +++ b/packages/schema-org/src/core/resolve.ts @@ -120,7 +120,7 @@ export function resolveNodeId(node: T, ctx: SchemaOrgGraph, res const hashNodeData: Record = {} Object.entries(node).forEach(([key, val]) => { // remove runtime private fields - if (!key.startsWith('_')) + if (key[0] !== '_') hashNodeData[key] = val }) node['@id'] = prefixId(ctx.meta[prefix], `#/schema/${alias}/${node['@id'] || hashCode(JSON.stringify(hashNodeData))}`) diff --git a/packages/schema-org/src/core/util.ts b/packages/schema-org/src/core/util.ts index 94c1767e..f57e49b3 100644 --- a/packages/schema-org/src/core/util.ts +++ b/packages/schema-org/src/core/util.ts @@ -59,7 +59,7 @@ export function normaliseNodes(nodes: SchemaOrgNode[]) { const nodeKey = resolveAsGraphKey(n['@id'] || hash(n)) as Id const groupedKeys = groupBy(Object.keys(n), (key) => { const val = n[key] - if (key.startsWith('_')) + if (key[0] === '_') return 'ignored' if (Array.isArray(val) || typeof val === 'object') return 'relations' diff --git a/packages/schema-org/src/utils/index.ts b/packages/schema-org/src/utils/index.ts index 5cd28b3c..a88dcbfa 100644 --- a/packages/schema-org/src/utils/index.ts +++ b/packages/schema-org/src/utils/index.ts @@ -88,7 +88,7 @@ export function prefixId(url: string, id: Id | string) { // already prefixed if (hasProtocol(id)) return id as Id - if (!id.startsWith('#')) + if (id[0] !== '#') id = `#${id}` return withBase(id, url) as Id } @@ -117,7 +117,7 @@ export function resolveDefaultType(node: Thing, defaultType: Arrayable) export function resolveWithBase(base: string, urlOrPath: string) { // can't apply base if there's a protocol - if (!urlOrPath || hasProtocol(urlOrPath) || (!urlOrPath.startsWith('/') && !urlOrPath.startsWith('#'))) + if (!urlOrPath || hasProtocol(urlOrPath) || ((urlOrPath[0] !== '/') && (urlOrPath[0] !== '#'))) return urlOrPath return withBase(urlOrPath, base) } diff --git a/packages/vue/src/env.ts b/packages/vue/src/env.ts index edd07752..bb4b6e62 100644 --- a/packages/vue/src/env.ts +++ b/packages/vue/src/env.ts @@ -1,3 +1,3 @@ import { version } from 'vue' -export const Vue3 = version.startsWith('3') +export const Vue3 = version[0] === '3' From 955b33e38cf641f3a1b2613cb36f3623d4e5e04b Mon Sep 17 00:00:00 2001 From: Negezor Date: Sat, 13 Jul 2024 03:03:07 +1100 Subject: [PATCH 03/78] perf: use Set + has instead of an array with includes --- packages/dom/src/renderDOMHead.ts | 6 +++--- packages/shared/src/constants.ts | 16 ++++++++-------- packages/shared/src/meta.ts | 16 +++++++--------- packages/shared/src/normalise.ts | 6 +++--- packages/shared/src/script.ts | 4 ++-- packages/shared/src/tagDedupeKey.ts | 2 +- packages/ssr/src/util/tagToString.ts | 6 +++--- packages/unhead/src/plugins/dedupe.ts | 6 +++--- packages/unhead/src/plugins/eventHandlers.ts | 17 +++++++++++++---- packages/unhead/src/plugins/hashKeyed.ts | 4 ++-- 10 files changed, 45 insertions(+), 38 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index a200317e..645b1803 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -31,7 +31,7 @@ export async function renderDOMHead>(head: T, options: Ren const tags = (await head.resolveTags()) .map(tag => { tag, - id: HasElementTags.includes(tag.tag) ? hashTag(tag) : tag.tag, + id: HasElementTags.has(tag.tag) ? hashTag(tag) : tag.tag, shouldRender: true, }) let state = head._dom as DomState @@ -43,7 +43,7 @@ export async function renderDOMHead>(head: T, options: Ren for (const key of ['body', 'head']) { const children = dom[key as 'head' | 'body']?.children const tags: HeadTag[] = [] - for (const c of [...children].filter(c => HasElementTags.includes(c.tagName.toLowerCase()))) { + for (const c of [...children].filter(c => HasElementTags.has(c.tagName.toLowerCase()))) { const t: HeadTag = { tag: c.tagName.toLowerCase() as HeadTag['tag'], props: await normaliseProps( @@ -148,7 +148,7 @@ export async function renderDOMHead>(head: T, options: Ren trackCtx(ctx) else // tag does not exist, we need to render it (if it's an element tag) - HasElementTags.includes(tag.tag) && pending.push(ctx) + HasElementTags.has(tag.tag) && pending.push(ctx) } // 3. render tags which require a dom element to be created or requires scanning DOM to determine duplicate for (const ctx of pending) { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3a9efe21..ef3ad9cb 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,14 +1,14 @@ -export const SelfClosingTags = ['meta', 'link', 'base'] -export const TagsWithInnerContent = ['title', 'titleTemplate', 'script', 'style', 'noscript'] -export const HasElementTags = [ +export const SelfClosingTags = new Set(['meta', 'link', 'base']) +export const TagsWithInnerContent = new Set(['title', 'titleTemplate', 'script', 'style', 'noscript']) +export const HasElementTags = new Set([ 'base', 'meta', 'link', 'style', 'script', 'noscript', -] -export const ValidHeadTags = [ +]) +export const ValidHeadTags = new Set([ 'title', 'titleTemplate', 'templateParams', @@ -20,11 +20,11 @@ export const ValidHeadTags = [ 'style', 'script', 'noscript', -] +]) -export const UniqueTags = ['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams'] +export const UniqueTags = new Set(['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams']) -export const TagConfigKeys = ['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'children', 'innerHTML', 'textContent', 'processTemplateParams'] +export const TagConfigKeys = new Set(['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'children', 'innerHTML', 'textContent', 'processTemplateParams']) export const IsBrowser = typeof window !== 'undefined' diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index 8b6a6efe..e03243f0 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -82,16 +82,15 @@ const MetaPackingSchema: Record = { }, } as const -const openGraphNamespaces = [ +const openGraphNamespaces = new Set([ 'og', 'book', 'article', 'profile', -] +]) export function resolveMetaKeyType(key: string): keyof BaseMeta { - const fKey = fixKeyCase(key).split(':')[0] - if (openGraphNamespaces.includes(fKey)) + if (openGraphNamespaces.has(fKey)) return 'property' return MetaPackingSchema[key]?.metaKey || 'name' } @@ -102,8 +101,7 @@ export function resolveMetaKeyValue(key: string): string { function fixKeyCase(key: string) { const updated = key.replace(/([A-Z])/g, '-$1').toLowerCase() - const fKey = updated.split('-')[0] - if (openGraphNamespaces.includes(fKey) || fKey === 'twitter') + if (openGraphNamespaces.has(fKey) || fKey === 'twitter') return key.replace(/([A-Z])/g, ':$1').toLowerCase() return updated } @@ -147,7 +145,7 @@ export function resolvePackedMetaObjectValue(value: string, key: string): string ) } -const ObjectArrayEntries = ['og:image', 'og:video', 'og:audio', 'twitter:image'] +const ObjectArrayEntries = new Set(['og:image', 'og:video', 'og:audio', 'twitter:image']) function sanitize(input: Record) { const out: Record = {} @@ -163,7 +161,7 @@ function handleObjectEntry(key: string, v: Record) { const value: Record = sanitize(v) const fKey = fixKeyCase(key) const attr = resolveMetaKeyType(fKey) - if (ObjectArrayEntries.includes(fKey as keyof MetaFlatInput)) { + if (ObjectArrayEntries.has(fKey as keyof MetaFlatInput)) { const input: MetaFlatInput = {} // we need to prefix the keys with og: Object.entries(value).forEach(([k, v]) => { @@ -189,7 +187,7 @@ export function unpackMeta(input: T): Required['m Object.entries(input).forEach(([key, value]) => { if (!Array.isArray(value)) { if (typeof value === 'object' && value) { - if (ObjectArrayEntries.includes(fixKeyCase(key) as keyof MetaFlatInput)) { + if (ObjectArrayEntries.has(fixKeyCase(key) as keyof MetaFlatInput)) { extras.push(...handleObjectEntry(key, value)) return } diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index b9b1e4d2..d1b6803a 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -20,7 +20,7 @@ export async function normaliseTag(tagName: T['tag'], input: const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] if (typeof val !== 'undefined') { // strip innerHTML and textContent for tags which don't support it= - if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.includes(tag.tag)) { + if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { // @ts-expect-error untyped tag[k === 'children' ? 'innerHTML' : k] = val } @@ -76,7 +76,7 @@ export async function normaliseProps(props: T['props'], virtu if (props[k] instanceof Promise) // @ts-expect-error untyped props[k] = await props[k] - if (!virtual && !TagConfigKeys.includes(k)) { + if (!virtual && !TagConfigKeys.has(k)) { const v = String(props[k]) // data keys get special treatment, we opt for more verbose syntax const isDataKey = k.startsWith('data-') @@ -102,7 +102,7 @@ export const TagEntityBits = 10 export async function normaliseEntryTags(e: HeadEntry): Promise { const tagPromises: Promise[] = [] Object.entries(e.resolvedInput as {}) - .filter(([k, v]) => typeof v !== 'undefined' && ValidHeadTags.includes(k)) + .filter(([k, v]) => typeof v !== 'undefined' && ValidHeadTags.has(k)) .forEach(([k, value]) => { const v = asArray(value) // @ts-expect-error untyped diff --git a/packages/shared/src/script.ts b/packages/shared/src/script.ts index 84fd4b81..b8b91d72 100644 --- a/packages/shared/src/script.ts +++ b/packages/shared/src/script.ts @@ -1,2 +1,2 @@ -export const NetworkEvents = ['onload', 'onerror', 'onabort', 'onprogress', 'onloadstart'] -export const ScriptNetworkEvents = ['onload', 'onerror'] +export const NetworkEvents = new Set(['onload', 'onerror', 'onabort', 'onprogress', 'onloadstart']) +export const ScriptNetworkEvents = new Set(['onload', 'onerror']) diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index a1bec7b7..be805e82 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -4,7 +4,7 @@ import { UniqueTags } from '.' export function tagDedupeKey(tag: T, fn?: (key: string) => boolean): string | false { const { props, tag: tagName } = tag // must only be a single base so we always dedupe - if (UniqueTags.includes(tagName)) + if (UniqueTags.has(tagName)) return tagName // support only a single canonical diff --git a/packages/ssr/src/util/tagToString.ts b/packages/ssr/src/util/tagToString.ts index 5136d78c..8bfbfa0f 100644 --- a/packages/ssr/src/util/tagToString.ts +++ b/packages/ssr/src/util/tagToString.ts @@ -36,13 +36,13 @@ export function tagToString(tag: T) { const attrs = propsToString(tag.props) const openTag = `<${tag.tag}${attrs}>` // get the encoding depending on the tag type - if (!TagsWithInnerContent.includes(tag.tag)) - return SelfClosingTags.includes(tag.tag) ? openTag : `${openTag}` + if (!TagsWithInnerContent.has(tag.tag)) + return SelfClosingTags.has(tag.tag) ? openTag : `${openTag}` // dangerously using innerHTML, we don't encode this let content = String(tag.innerHTML || '') if (tag.textContent) // content needs to be encoded to avoid XSS, only for title content = escapeHtml(String(tag.textContent)) - return SelfClosingTags.includes(tag.tag) ? openTag : `${openTag}${content}` + return SelfClosingTags.has(tag.tag) ? openTag : `${openTag}${content}` } diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 78f26ef4..aa56d842 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -1,7 +1,7 @@ import type { HeadTag } from '@unhead/schema' import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhead/shared' -const UsesMergeStrategy = ['templateParams', 'htmlAttrs', 'bodyAttrs'] +const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) export default defineHeadPlugin({ hooks: { @@ -29,7 +29,7 @@ export default defineHeadPlugin({ if (dupedTag) { // default strategy is replace, unless we're dealing with a html or body attrs let strategy = tag?.tagDuplicateStrategy - if (!strategy && UsesMergeStrategy.includes(tag.tag)) + if (!strategy && UsesMergeStrategy.has(tag.tag)) strategy = 'merge' if (strategy === 'merge') { @@ -72,7 +72,7 @@ export default defineHeadPlugin({ } const propCount = Object.keys(tag.props).length + (tag.innerHTML ? 1 : 0) + (tag.textContent ? 1 : 0) // if the new tag does not have any props, we're trying to remove the duped tag from the DOM - if (HasElementTags.includes(tag.tag) && propCount === 0) { + if (HasElementTags.has(tag.tag) && propCount === 0) { // find the tag with the same key delete deduping[dedupeKey] return diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index b6966d43..ca729062 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -1,6 +1,6 @@ import { NetworkEvents, defineHeadPlugin, hashCode } from '@unhead/shared' -const ValidEventTags = ['script', 'link', 'bodyAttrs'] +const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) /** * Supports DOM event handlers (i.e `onload`) as functions. @@ -10,13 +10,13 @@ const ValidEventTags = ['script', 'link', 'bodyAttrs'] export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': function (ctx) { - for (const tag of ctx.tags.filter(t => ValidEventTags.includes(t.tag))) { + for (const tag of ctx.tags.filter(t => ValidEventTags.has(t.tag))) { // must be a valid tag Object.entries(tag.props) .forEach(([key, value]) => { if (key.startsWith('on') && typeof value === 'function') { // insert a inline script to set the status of onload and onerror - if (head.ssr && NetworkEvents.includes(key)) + if (head.ssr && NetworkEvents.has(key)) tag.props[key] = `this.dataset.${key}fired = true` else delete tag.props[key] @@ -30,8 +30,17 @@ export default defineHeadPlugin(head => ({ }, 'dom:renderTag': function ({ $el, tag }) { // this is only handling SSR rendered tags with event handlers - for (const k of Object.keys($el?.dataset as HTMLScriptElement || {}).filter(k => NetworkEvents.some(e => `${e}fired` === k))) { + for (const k in (($el as HTMLScriptElement | undefined)?.dataset || {})) { + if (!k.endsWith('fired')) { + continue + } + const ek = k.replace('fired', '') + + if (!NetworkEvents.has(ek)) { + continue + } + tag._eventHandlers?.[ek]?.call($el, new Event(ek.replace('on', ''))) } }, diff --git a/packages/unhead/src/plugins/hashKeyed.ts b/packages/unhead/src/plugins/hashKeyed.ts index 92f1839b..69dbb3c1 100644 --- a/packages/unhead/src/plugins/hashKeyed.ts +++ b/packages/unhead/src/plugins/hashKeyed.ts @@ -1,13 +1,13 @@ import { defineHeadPlugin, hashCode } from '@unhead/shared' -const DupeableTags = ['link', 'style', 'script', 'noscript'] +const DupeableTags = new Set(['link', 'style', 'script', 'noscript']) export default defineHeadPlugin({ hooks: { 'tag:normalise': ({ tag }) => { // only if the user has provided a key // only tags which can't dedupe themselves, ssr only - if (tag.key && DupeableTags.includes(tag.tag)) { + 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!) } From d241e9cfc18c1e250b85cc856934ee5c07d47a6e Mon Sep 17 00:00:00 2001 From: Negezor Date: Sat, 13 Jul 2024 03:05:03 +1100 Subject: [PATCH 04/78] perf(shared): implement split once for fixKeyCase and resolveMetaKeyType --- packages/shared/src/meta.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index e03243f0..c3a54b28 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -90,7 +90,9 @@ const openGraphNamespaces = new Set([ ]) export function resolveMetaKeyType(key: string): keyof BaseMeta { - if (openGraphNamespaces.has(fKey)) + const fKey = fixKeyCase(key) + const prefixIndex = fKey.indexOf(':') + if (openGraphNamespaces.has(fKey.substring(0, prefixIndex))) return 'property' return MetaPackingSchema[key]?.metaKey || 'name' } @@ -101,6 +103,8 @@ export function resolveMetaKeyValue(key: string): string { function fixKeyCase(key: string) { const updated = key.replace(/([A-Z])/g, '-$1').toLowerCase() + const prefixIndex = updated.indexOf('-') + const fKey = updated.substring(0, prefixIndex) if (openGraphNamespaces.has(fKey) || fKey === 'twitter') return key.replace(/([A-Z])/g, ':$1').toLowerCase() return updated From 2552521b39c8f9081ea896e0b368b76bfc9301f3 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sat, 13 Jul 2024 23:55:12 +1100 Subject: [PATCH 05/78] feat(shared): introduce thenable helper --- packages/shared/src/index.ts | 1 + packages/shared/src/thenable.ts | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 packages/shared/src/thenable.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 898ac8d1..1ae8e8b9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,4 +9,5 @@ export * from './safe' export * from './normalise' export * from './sort' export * from './script' +export * from './thenable' export * from './templateParams' diff --git a/packages/shared/src/thenable.ts b/packages/shared/src/thenable.ts new file mode 100644 index 00000000..6cc8e658 --- /dev/null +++ b/packages/shared/src/thenable.ts @@ -0,0 +1,9 @@ +export type Thenable = Promise | T + +export function thenable(val: T, thenFn: (val: Awaited) => R): Promise | R { + if (val instanceof Promise) { + return val.then(thenFn) + } + + return thenFn(val as Awaited) +} From 5f7418d28f75d5840999df07866074ea144195f3 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sat, 13 Jul 2024 23:58:18 +1100 Subject: [PATCH 06/78] perf(shared): use thenable in normalise for reduce async/await functions --- packages/shared/src/normalise.ts | 148 +++++++++++++++++-------------- 1 file changed, 83 insertions(+), 65 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index d1b6803a..17ce19af 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -1,11 +1,10 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' +import { type Thenable, thenable } from './thenable' import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags, asArray } from '.' -export async function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Promise { - // input can be a function or an object, we need to clone it - const tag = { - tag: tagName, - props: await normaliseProps( +export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Thenable { + return thenable( + normaliseProps( // explicitly check for an object // @ts-expect-error untyped typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) @@ -13,38 +12,45 @@ export async function normaliseTag(tagName: T['tag'], input: : { [(tagName === 'script' || tagName === 'noscript' || tagName === 'style') ? 'innerHTML' : 'textContent']: input }, (tagName === 'templateParams' || tagName === 'titleTemplate'), ), - } as T - // merge options from the entry - TagConfigKeys.forEach((k) => { - // @ts-expect-error untyped - const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] - if (typeof val !== 'undefined') { - // strip innerHTML and textContent for tags which don't support it= - if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { + (props) => { + // input can be a function or an object, we need to clone it + const tag = { + tag: tagName, + props: props as T['props'], + } as T + // merge options from the entry + TagConfigKeys.forEach((k) => { // @ts-expect-error untyped - tag[k === 'children' ? 'innerHTML' : k] = val + const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] + if (typeof val !== 'undefined') { + // strip innerHTML and textContent for tags which don't support it= + if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { + // @ts-expect-error untyped + tag[k === 'children' ? 'innerHTML' : k] = val + } + delete tag.props[k] + } + }) + // TODO remove v2 + if (tag.props.body) { + // inserting dangerous javascript potentially + tag.tagPosition = 'bodyClose' + // clean up + delete tag.props.body } - delete tag.props[k] - } - }) - // TODO remove v2 - if (tag.props.body) { - // inserting dangerous javascript potentially - tag.tagPosition = 'bodyClose' - // clean up - delete tag.props.body - } - // shorthand for objects - if (tag.tag === 'script') { - if (typeof tag.innerHTML === 'object') { - tag.innerHTML = JSON.stringify(tag.innerHTML) - tag.props.type = tag.props.type || 'application/json' - } - } - // allow meta to be resolved into multiple tags if an array is provided on content - return Array.isArray(tag.props.content) - ? tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T)) - : tag + // shorthand for objects + if (tag.tag === 'script') { + if (typeof tag.innerHTML === 'object') { + tag.innerHTML = JSON.stringify(tag.innerHTML) + tag.props.type = tag.props.type || 'application/json' + } + } + // allow meta to be resolved into multiple tags if an array is provided on content + return Array.isArray(tag.props.content) + ? tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T)) + : tag + }, + ) } export function normaliseStyleClassProps(key: T, v: Required['htmlAttrs']['class']> | Required['htmlAttrs']['style']>) { @@ -62,44 +68,53 @@ export function normaliseStyleClassProps(key: T, v: .join(sep) } -export async function normaliseProps(props: T['props'], virtual?: boolean): Promise { - // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes +export function normaliseProps(props: T['props'], virtual?: boolean): Thenable { + let thanableRet: Thenable + for (const k of Object.keys(props)) { + // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes // class has special handling if (k === 'class' || k === 'style') { // @ts-expect-error untyped props[k] = normaliseStyleClassProps(k, props[k]) continue } - // first resolve any promises - // @ts-expect-error untyped - if (props[k] instanceof Promise) + + const result = thenable(props[k], (prop) => { // @ts-expect-error untyped - props[k] = await props[k] - if (!virtual && !TagConfigKeys.has(k)) { - const v = String(props[k]) - // data keys get special treatment, we opt for more verbose syntax - const isDataKey = k.startsWith('data-') - if (v === 'true' || v === '') { - // @ts-expect-error untyped - props[k] = isDataKey ? 'true' : true - } - else if (!props[k]) { - if (isDataKey && v === 'false') + props[k] = prop + + if (!virtual && !TagConfigKeys.has(k as string)) { + const v = String(props[k]) + // data keys get special treatment, we opt for more verbose syntax + const isDataKey = k.startsWith('data-') + if (v === 'true' || v === '') { // @ts-expect-error untyped - props[k] = 'false' - else - delete props[k] + props[k] = isDataKey ? 'true' : true + } + else if (!props[k]) { + if (isDataKey && v === 'false') + // @ts-expect-error untyped + props[k] = 'false' + else + delete props[k] + } } + }) + + if (result instanceof Promise) { + const prevThenableRet = thanableRet + thanableRet = result.then(() => prevThenableRet) } } - return props + + return thenable(thanableRet, () => props) } // support 1024 tag ids per entry (includes updates) export const TagEntityBits = 10 -export async function normaliseEntryTags(e: HeadEntry): Promise { +export function normaliseEntryTags(e: HeadEntry): Promise { const tagPromises: Promise[] = [] Object.entries(e.resolvedInput as {}) .filter(([k, v]) => typeof v !== 'undefined' && ValidHeadTags.has(k)) @@ -108,13 +123,16 @@ export async function normaliseEntryTags(e: HeadEntry): // @ts-expect-error untyped tagPromises.push(...v.map(props => normaliseTag(k as keyof Head, props, e)).flat()) }) - return (await Promise.all(tagPromises)) - .flat() - .filter(Boolean) - .map((t: HeadTag, i) => { - t._e = e._i - e.mode && (t._m = e.mode) - t._p = (e._i << TagEntityBits) + i - return t - }) as unknown as HeadTag[] + return Promise.all(tagPromises) + .then(headTags => ( + headTags + .flat() + .filter(Boolean) + .map((t: HeadTag, i) => { + t._e = e._i + e.mode && (t._m = e.mode) + t._p = (e._i << TagEntityBits) + i + return t + }) as unknown as HeadTag[] + )) } From f845e8d69eeaec9bc8f6c41fc22f3aef505764d6 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 01:25:02 +1100 Subject: [PATCH 07/78] perf(dom): use promise chain instead of async function in debouncedRenderDOMHead --- packages/dom/src/debounced.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/dom/src/debounced.ts b/packages/dom/src/debounced.ts index fa1c7aa8..ded4199d 100644 --- a/packages/dom/src/debounced.ts +++ b/packages/dom/src/debounced.ts @@ -14,9 +14,11 @@ export interface DebouncedRenderDomHeadOptions extends RenderDomHeadOptions { */ export async function debouncedRenderDOMHead>(head: T, options: DebouncedRenderDomHeadOptions = {}) { const fn = options.delayFn || (fn => setTimeout(fn, 10)) - return head._domUpdatePromise = head._domUpdatePromise || new Promise(resolve => fn(async () => { - await renderDOMHead(head, options) - delete head._domUpdatePromise - resolve() + return head._domUpdatePromise = head._domUpdatePromise || new Promise(resolve => fn(() => { + return renderDOMHead(head, options) + .then(() => { + delete head._domUpdatePromise + resolve() + }) })) } From 0b84990231f03672919327d96ca7363f44bf6b88 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 01:27:06 +1100 Subject: [PATCH 08/78] chore(dom): remove async modifier for debouncedRenderDOMHead --- packages/dom/src/debounced.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dom/src/debounced.ts b/packages/dom/src/debounced.ts index ded4199d..250ca648 100644 --- a/packages/dom/src/debounced.ts +++ b/packages/dom/src/debounced.ts @@ -12,7 +12,7 @@ export interface DebouncedRenderDomHeadOptions extends RenderDomHeadOptions { /** * Queue a debounced update of the DOM head. */ -export async function debouncedRenderDOMHead>(head: T, options: DebouncedRenderDomHeadOptions = {}) { +export function debouncedRenderDOMHead>(head: T, options: DebouncedRenderDomHeadOptions = {}) { const fn = options.delayFn || (fn => setTimeout(fn, 10)) return head._domUpdatePromise = head._domUpdatePromise || new Promise(resolve => fn(() => { return renderDOMHead(head, options) From db9392626d517622f921562b834a425fbfd8eb23 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 03:47:54 +1100 Subject: [PATCH 09/78] perf(shared): remove unnecessary array spread in tagDedupeKey --- packages/shared/src/tagDedupeKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index be805e82..4ee13f8b 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -17,7 +17,7 @@ export function tagDedupeKey(tag: T, fn?: (key: string) => bo const name = ['id'] if (tagName === 'meta') - name.push(...['name', 'property', 'http-equiv']) + name.push('name', 'property', 'http-equiv') for (const n of name) { // open graph props can have multiple tags with the same property if (typeof props[n] !== 'undefined') { From 933380add36c8db0eeaeb9674af126422ab7c39f Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 04:12:25 +1100 Subject: [PATCH 10/78] refactor: undefined is allowed for spread object --- packages/addons/src/unplugin/vite.ts | 4 ++-- packages/dom/src/renderDOMHead.ts | 2 +- packages/unhead/src/composables/useHeadSafe.ts | 4 ++-- packages/unhead/src/composables/useServerSeoMeta.ts | 2 +- packages/vue/src/composables/useServerSeoMeta.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/addons/src/unplugin/vite.ts b/packages/addons/src/unplugin/vite.ts index ed4504d8..718eeb8c 100644 --- a/packages/addons/src/unplugin/vite.ts +++ b/packages/addons/src/unplugin/vite.ts @@ -7,7 +7,7 @@ export type { UnpluginOptions } export default (options: UnpluginOptions = {}): Plugin[] => { return [ - TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake || {} }), - UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta || {} }), + TreeshakeServerComposables.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.treeshake }), + UseSeoMetaTransform.vite({ filter: options.filter, sourcemap: options.sourcemap, ...options.transformSeoMeta }), ] } diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 645b1803..5c1c20b0 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -65,7 +65,7 @@ export async function renderDOMHead>(head: T, options: Ren } // presume all side effects are stale, we mark them as not stale if they're re-introduced - state.pendingSideEffects = { ...state.sideEffects || {} } + state.pendingSideEffects = { ...state.sideEffects } state.sideEffects = {} function track(id: string, scope: string, fn: () => void) { diff --git a/packages/unhead/src/composables/useHeadSafe.ts b/packages/unhead/src/composables/useHeadSafe.ts index be7f32a1..f518aca2 100644 --- a/packages/unhead/src/composables/useHeadSafe.ts +++ b/packages/unhead/src/composables/useHeadSafe.ts @@ -6,10 +6,10 @@ import type { import { whitelistSafeInput } from '@unhead/shared' import { useHead } from './useHead' -export function useHeadSafe(input: HeadSafe, options: HeadEntryOptions = {}): ActiveHeadEntry | void { +export function useHeadSafe(input: HeadSafe, options?: HeadEntryOptions): ActiveHeadEntry | void { // @ts-expect-error untyped return useHead(input, { - ...(options || {}), + ...options, transform: whitelistSafeInput, }) } diff --git a/packages/unhead/src/composables/useServerSeoMeta.ts b/packages/unhead/src/composables/useServerSeoMeta.ts index ef604bea..9a585c3c 100644 --- a/packages/unhead/src/composables/useServerSeoMeta.ts +++ b/packages/unhead/src/composables/useServerSeoMeta.ts @@ -3,7 +3,7 @@ import { useSeoMeta } from './useSeoMeta' export function useServerSeoMeta(input: UseSeoMetaInput, options?: HeadEntryOptions): ActiveHeadEntry | void { return useSeoMeta(input, { - ...(options || {}), + ...options, mode: 'server', }) } diff --git a/packages/vue/src/composables/useServerSeoMeta.ts b/packages/vue/src/composables/useServerSeoMeta.ts index da29f9f6..ec9dfcfc 100644 --- a/packages/vue/src/composables/useServerSeoMeta.ts +++ b/packages/vue/src/composables/useServerSeoMeta.ts @@ -3,5 +3,5 @@ import type { UseHeadOptions, UseSeoMetaInput } from '../types' import { useSeoMeta } from './useSeoMeta' export function useServerSeoMeta(input: UseSeoMetaInput, options?: UseHeadOptions): ActiveHeadEntry | void { - return useSeoMeta(input, { ...(options || {}), mode: 'server' }) + return useSeoMeta(input, { ...options, mode: 'server' }) } From 159ea79262d8b3da044539dfab79e1049faf7b96 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 04:29:24 +1100 Subject: [PATCH 11/78] perf(schema-org): avoid new array allocation in dedupeMerge --- packages/schema-org/src/utils/index.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/schema-org/src/utils/index.ts b/packages/schema-org/src/utils/index.ts index a88dcbfa..02a96193 100644 --- a/packages/schema-org/src/utils/index.ts +++ b/packages/schema-org/src/utils/index.ts @@ -75,13 +75,10 @@ export function asArray(input: any) { } export function dedupeMerge(node: T, field: keyof T, value: any) { - const dedupeMerge: any[] = [] - const input = asArray(node[field]) - dedupeMerge.push(...input) - const data = new Set(dedupeMerge) + const data = new Set(asArray(node[field])) data.add(value) // @ts-expect-error untyped key - node[field] = [...data.values()].filter(Boolean) + node[field] = [...data].filter(Boolean) } export function prefixId(url: string, id: Id | string) { From e11e9c251dd0ddd8c5efdd3e78d687d743755640 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 04:58:01 +1100 Subject: [PATCH 12/78] perf(dom): check empty class or style in foreach instead of allocation of filter --- packages/dom/src/renderDOMHead.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 5c1c20b0..75924ba1 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -102,7 +102,10 @@ export async function renderDOMHead>(head: T, options: Ren if (k === 'class') { // if the user is providing an empty string, then it's removing the class // the side effect clean up should remove it - for (const c of (value || '').split(' ').filter(Boolean)) { + for (const c of (value || '').split(' ')) { + if (!c) { + continue + } // always clear side effects isAttrTag && track(id, `${ck}:${c}`, () => $el.classList.remove(c)) !$el.classList.contains(c) && $el.classList.add(c) @@ -110,7 +113,10 @@ export async function renderDOMHead>(head: T, options: Ren } else if (k === 'style') { // style attributes have their own side effects to allow for merging - for (const c of (value || '').split(';').filter(Boolean)) { + for (const c of (value || '').split(';')) { + if (!c) { + continue + } const [k, ...v] = c.split(':').map(s => s.trim()) track(id, `${ck}:${k}`, () => { ($el as any as ElementCSSInlineStyle).style.removeProperty(k) From 54b9efa05e37778641183015a46f79a1882d3ffe Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 05:13:17 +1100 Subject: [PATCH 13/78] perf(dom): implement split once for style in trackCtx --- packages/dom/src/renderDOMHead.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 75924ba1..20586062 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -117,11 +117,13 @@ export async function renderDOMHead>(head: T, options: Ren if (!c) { continue } - const [k, ...v] = c.split(':').map(s => s.trim()) + const propIndex = c.indexOf(':') + const k = c.substring(0, propIndex).trim() + const v = c.substring(propIndex + 1).trim() track(id, `${ck}:${k}`, () => { ($el as any as ElementCSSInlineStyle).style.removeProperty(k) }) - ;($el as any as ElementCSSInlineStyle).style.setProperty(k, v.join(':')) + ;($el as any as ElementCSSInlineStyle).style.setProperty(k, v) } } else { From d753005891b12436c08dda43c5719d6a10cb3bf8 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 05:35:53 +1100 Subject: [PATCH 14/78] perf(dom): convert tag name to lowercase once in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 20586062..92be5ec9 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -43,9 +43,13 @@ export async function renderDOMHead>(head: T, options: Ren for (const key of ['body', 'head']) { const children = dom[key as 'head' | 'body']?.children const tags: HeadTag[] = [] - for (const c of [...children].filter(c => HasElementTags.has(c.tagName.toLowerCase()))) { + for (const c of [...children]) { + const tag = c.tagName.toLowerCase() as HeadTag['tag'] + if (!HasElementTags.has(tag)) { + continue + } const t: HeadTag = { - tag: c.tagName.toLowerCase() as HeadTag['tag'], + tag, props: await normaliseProps( c.getAttributeNames() .reduce((props, name) => ({ ...props, [name]: c.getAttribute(name) }), {}), From 0ee43de14715513ad37b51737d11cea954e7d16c Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 06:01:03 +1100 Subject: [PATCH 15/78] refactor: compare with undefined without typeof in safe places --- packages/addons/src/plugins/inferSeoMetaPlugin.ts | 2 +- packages/addons/src/unplugin/TreeshakeServerComposables.ts | 2 +- packages/schema-org/src/utils/index.ts | 2 +- packages/shared/src/normalise.ts | 6 +++--- packages/shared/src/tagDedupeKey.ts | 2 +- packages/shared/src/templateParams.ts | 2 +- packages/unhead/src/composables/useScript.ts | 4 ++-- packages/unhead/src/plugins/sort.ts | 2 +- packages/vue/src/VueUseHeadPolyfill.ts | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/addons/src/plugins/inferSeoMetaPlugin.ts b/packages/addons/src/plugins/inferSeoMetaPlugin.ts index e6c34070..e9db35ec 100644 --- a/packages/addons/src/plugins/inferSeoMetaPlugin.ts +++ b/packages/addons/src/plugins/inferSeoMetaPlugin.ts @@ -32,7 +32,7 @@ export interface InferSeoMetaPluginOptions { for (const entry of entries) { const inputKey = entry.resolvedInput ? 'resolvedInput' : 'input' const input = entry[inputKey] - if (typeof input.titleTemplate !== 'undefined') + if (input.titleTemplate !== undefined) titleTemplate = input.titleTemplate } diff --git a/packages/addons/src/unplugin/TreeshakeServerComposables.ts b/packages/addons/src/unplugin/TreeshakeServerComposables.ts index 614992e8..bbd4f770 100644 --- a/packages/addons/src/unplugin/TreeshakeServerComposables.ts +++ b/packages/addons/src/unplugin/TreeshakeServerComposables.ts @@ -23,7 +23,7 @@ export interface TreeshakeServerComposablesOptions extends BaseTransformerTypes } export const TreeshakeServerComposables = createUnplugin((options: TreeshakeServerComposablesOptions = {}) => { - options.enabled = typeof options.enabled !== 'undefined' ? options.enabled : true + options.enabled = options.enabled !== undefined ? options.enabled : true return { name: 'unhead:remove-server-composables', diff --git a/packages/schema-org/src/utils/index.ts b/packages/schema-org/src/utils/index.ts index 02a96193..195c1d72 100644 --- a/packages/schema-org/src/utils/index.ts +++ b/packages/schema-org/src/utils/index.ts @@ -137,7 +137,7 @@ export function stripEmptyProperties(obj: any) { stripEmptyProperties(obj[k]) return } - if (obj[k] === '' || obj[k] === null || typeof obj[k] === 'undefined') + if (obj[k] === '' || obj[k] === null || obj[k] === undefined) delete obj[k] }) return obj diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 17ce19af..a68ab5f8 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -21,8 +21,8 @@ export function normaliseTag(tagName: T['tag'], input: HeadTa // merge options from the entry TagConfigKeys.forEach((k) => { // @ts-expect-error untyped - const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k] - if (typeof val !== 'undefined') { + const val = tag.props[k] !== undefined ? tag.props[k] : e[k] + if (val !== undefined) { // strip innerHTML and textContent for tags which don't support it= if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { // @ts-expect-error untyped @@ -117,7 +117,7 @@ export const TagEntityBits = 10 export function normaliseEntryTags(e: HeadEntry): Promise { const tagPromises: Promise[] = [] Object.entries(e.resolvedInput as {}) - .filter(([k, v]) => typeof v !== 'undefined' && ValidHeadTags.has(k)) + .filter(([k, v]) => v !== undefined && ValidHeadTags.has(k)) .forEach(([k, value]) => { const v = asArray(value) // @ts-expect-error untyped diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index 4ee13f8b..4af9276b 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -20,7 +20,7 @@ export function tagDedupeKey(tag: T, fn?: (key: string) => bo name.push('name', 'property', 'http-equiv') for (const n of name) { // open graph props can have multiple tags with the same property - if (typeof props[n] !== 'undefined') { + if (props[n] !== undefined) { const val = String(props[n]) if (fn && !fn(val)) return false diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index 05980d7a..77dff513 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -18,7 +18,7 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) val = token.split('.').reduce((acc, key) => acc ? (acc[key] || undefined) : undefined, p) as string } else { val = p[token] as string | undefined } - return typeof val !== 'undefined' + return val !== undefined // need to escape val for json ? (val || '').replace(/"/g, '\\"') : false diff --git a/packages/unhead/src/composables/useScript.ts b/packages/unhead/src/composables/useScript.ts index 3a8d92e2..baccace3 100644 --- a/packages/unhead/src/composables/useScript.ts +++ b/packages/unhead/src/composables/useScript.ts @@ -29,7 +29,7 @@ export function useScript>(_input: UseScr script.status = s head.hooks.callHook(`script:updated`, hookCtx) } - const trigger = typeof options.trigger !== 'undefined' ? options.trigger : 'client' + const trigger = options.trigger !== undefined ? options.trigger : 'client' ScriptNetworkEvents .forEach((fn) => { const _fn = typeof input[fn] === 'function' ? input[fn].bind(options.eventContext) : null @@ -110,7 +110,7 @@ export function useScript>(_input: UseScr return stub if (k === '$script') return proxy.$script - const exists = _ && k in _ && typeof _[k] !== 'undefined' + const exists = _ && k in _ && _[k] !== undefined head.hooks.callHook('script:instance-fn', { script, fn: k, exists }) return exists ? Reflect.get(_, k) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index e75da2e4..97dc907d 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -12,7 +12,7 @@ export default defineHeadPlugin({ const position = tagPositionForKey( (tag.tagPriority as string).replace(prefix, ''), ) - if (typeof position !== 'undefined') + if (position !== undefined) tag._p = position + offset } } diff --git a/packages/vue/src/VueUseHeadPolyfill.ts b/packages/vue/src/VueUseHeadPolyfill.ts index b10f13a4..d8d223f2 100644 --- a/packages/vue/src/VueUseHeadPolyfill.ts +++ b/packages/vue/src/VueUseHeadPolyfill.ts @@ -44,7 +44,7 @@ export function polyfillAsVueUseHead(head: VueHeadClient polyfilled.addHeadObjs = head.push polyfilled.addReactiveEntry = (input, options) => { const api = useHead(input, options) - if (typeof api !== 'undefined') + if (api !== undefined) return api.dispose return () => {} } From 598d8e60c09ee8846dabdbeae4170f99795789b1 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 18:51:55 +1100 Subject: [PATCH 16/78] test(shared): add benchmark for processTemplateParams --- package.json | 3 +- test/shared/templateParams.bench.ts | 46 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 test/shared/templateParams.bench.ts diff --git a/package.json b/package.json index 78f8b14c..8056353d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "export:sizes": "pnpm -r --parallel --filter=./packages/** run export:sizes", "bump": "bumpp package.json packages/*/package.json --commit --push --tag", "release": "pnpm build && pnpm bump && pnpm -r publish --no-git-checks", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "benchmark": "vitest bench" }, "devDependencies": { "@antfu/eslint-config": "^2.21.2", diff --git a/test/shared/templateParams.bench.ts b/test/shared/templateParams.bench.ts new file mode 100644 index 00000000..7745d5df --- /dev/null +++ b/test/shared/templateParams.bench.ts @@ -0,0 +1,46 @@ +import { bench, describe } from 'vitest' + +import { processTemplateParams } from '@unhead/shared' + +describe('processTemplateParams', () => { + bench('basic', () => { + processTemplateParams('%s %separator %siteName', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('nested props', () => { + processTemplateParams('%params.nested %anotherParams.nested', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + params: { + nested: 'yes', + }, + anotherParams: { + nested: 'another yes', + }, + }, '/') + }) + + bench('not found props', () => { + processTemplateParams('%test %another %name %value', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('with url', () => { + processTemplateParams('https://cdn.example.com/some%20image.jpg', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) + + bench('simple string', () => { + processTemplateParams('My Awesome Simple String', { + pageTitle: 'hello world', + siteName: 'My Awesome Site', + }, '/') + }) +}) From a8a3f5a81e196d7450772500657d275f09c9aa16 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 18:53:50 +1100 Subject: [PATCH 17/78] perf(shared): move sub to top module in templateParams --- packages/shared/src/templateParams.ts | 38 +++++++++++++-------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index 77dff513..9ec81d71 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -2,28 +2,28 @@ import type { TemplateParams } from '@unhead/schema' const sepSub = '%separator' +// for each % token replace it with the corresponding runtime config or an empty value +function sub(p: TemplateParams, token: string) { + let val: string | undefined + if (token === 's' || token === 'pageTitle') { + val = p.pageTitle as string + } + // support . notation + else if (token.includes('.')) { + // @ts-expect-error untyped + val = token.split('.').reduce((acc, key) => acc ? (acc[key] || undefined) : undefined, p) as string + } + else { val = p[token] as string | undefined } + return val !== undefined + // need to escape val for json + ? (val || '').replace(/"/g, '\\"') + : false +} + export function processTemplateParams(s: string, p: TemplateParams, sep: string) { // return early if (typeof s !== 'string' || !s.includes('%')) return s - // for each % token replace it with the corresponding runtime config or an empty value - function sub(token: string) { - let val: string | undefined - if (token === 's' || token === 'pageTitle') { - val = p.pageTitle as string - } - // support . notation - else if (token.includes('.')) { - // @ts-expect-error untyped - val = token.split('.').reduce((acc, key) => acc ? (acc[key] || undefined) : undefined, p) as string - } - else { val = p[token] as string | undefined } - return val !== undefined - // need to escape val for json - ? (val || '').replace(/"/g, '\\"') - : false - } - // need to avoid replacing url encoded values let decoded = s try { @@ -34,7 +34,7 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) const tokens: string[] = (decoded.match(/%(\w+\.+\w+)|%(\w+)/g) || []).sort().reverse() // for each tokens, replace in the original string s tokens.forEach((token) => { - const re = sub(token.slice(1)) + const re = sub(p, token.slice(1)) if (typeof re === 'string') { // replace the re using regex as word separators s = s.replace(new RegExp(`\\${token}(\\W|$)`, 'g'), (_, args) => `${re}${args}`).trim() From 2846564b1f5a63fb8351a8c78e69cd42f6256e32 Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 19:22:32 +1100 Subject: [PATCH 18/78] perf(shared): avoid unnecessary operations in processTemplateParams --- packages/shared/src/templateParams.ts | 34 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index 9ec81d71..e2d9e4aa 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -10,16 +10,19 @@ function sub(p: TemplateParams, token: string) { } // support . notation else if (token.includes('.')) { + const dotIndex = token.indexOf('.') // @ts-expect-error untyped - val = token.split('.').reduce((acc, key) => acc ? (acc[key] || undefined) : undefined, p) as string + val = p[token.substring(0, dotIndex)]?.[token.substring(dotIndex + 1)] } else { val = p[token] as string | undefined } return val !== undefined // need to escape val for json ? (val || '').replace(/"/g, '\\"') - : false + : undefined } +const sepSubRe = new RegExp(`${sepSub}(?:\\s*${sepSub})*`, 'g') + export function processTemplateParams(s: string, p: TemplateParams, sep: string) { // return early if (typeof s !== 'string' || !s.includes('%')) @@ -31,27 +34,38 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) } catch {} // find all tokens in decoded - const tokens: string[] = (decoded.match(/%(\w+\.+\w+)|%(\w+)/g) || []).sort().reverse() + const tokens = decoded.match(/%\w+(?:\.\w+)?/g) + + if (!tokens) { + return s + } + + tokens.sort().reverse() + + const hasSepSub = s.includes(sepSub) + // for each tokens, replace in the original string s tokens.forEach((token) => { + if (token === sepSub) { + return + } const re = sub(p, token.slice(1)) - if (typeof re === 'string') { + if (re !== undefined) { // replace the re using regex as word separators - s = s.replace(new RegExp(`\\${token}(\\W|$)`, 'g'), (_, args) => `${re}${args}`).trim() + s = s.replace(new RegExp(`\\${token}(\\W|$)`, 'g'), (_, args) => re + args).trim() } }) // we wait to transform the separator as we need to transform all other tokens first // we need to remove separators if they're next to each other or if they're at the start or end of the string // for example: %separator %separator %title should return %title - if (s.includes(sepSub)) { + if (hasSepSub) { if (s.endsWith(sepSub)) - s = s.slice(0, -sepSub.length).trim() + s = s.slice(0, -sepSub.length) if (s.startsWith(sepSub)) - s = s.slice(sepSub.length).trim() + s = s.slice(sepSub.length) // make sure we don't have two separators next to each other - s = s.replace(new RegExp(`\\${sepSub}\\s*\\${sepSub}`, 'g'), sepSub) - s = processTemplateParams(s, { separator: sep }, sep) + s = s.replace(sepSubRe, sep).trim() } return s } From a683e20638f1cba0cea4d08045cb3caf18e1182c Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 20:00:04 +1100 Subject: [PATCH 19/78] perf(shared): use single replacer in processTemplateParams --- packages/shared/src/templateParams.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/templateParams.ts b/packages/shared/src/templateParams.ts index e2d9e4aa..cce2cf68 100644 --- a/packages/shared/src/templateParams.ts +++ b/packages/shared/src/templateParams.ts @@ -40,21 +40,18 @@ export function processTemplateParams(s: string, p: TemplateParams, sep: string) return s } - tokens.sort().reverse() - const hasSepSub = s.includes(sepSub) - // for each tokens, replace in the original string s - tokens.forEach((token) => { - if (token === sepSub) { - return + s = s.replace(/%\w+(?:\.\w+)?/g, (token) => { + if (token === sepSub || !tokens.includes(token)) { + return token } + const re = sub(p, token.slice(1)) - if (re !== undefined) { - // replace the re using regex as word separators - s = s.replace(new RegExp(`\\${token}(\\W|$)`, 'g'), (_, args) => re + args).trim() - } - }) + return re !== undefined + ? re + : token + }).trim() // we wait to transform the separator as we need to transform all other tokens first // we need to remove separators if they're next to each other or if they're at the start or end of the string From 1c03cce25ad0509f9e49dec020fd5b74f9b3c69b Mon Sep 17 00:00:00 2001 From: Negezor Date: Sun, 14 Jul 2024 20:13:58 +1100 Subject: [PATCH 20/78] refactor(dom): move condition to else in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 92be5ec9..f32756dd 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -156,11 +156,13 @@ export async function renderDOMHead>(head: T, options: Ren continue } ctx.$el = ctx.$el || state.elMap[id] - if (ctx.$el) + if (ctx.$el) { trackCtx(ctx) - else + } + else if (HasElementTags.has(tag.tag)) { // tag does not exist, we need to render it (if it's an element tag) - HasElementTags.has(tag.tag) && pending.push(ctx) + pending.push(ctx) + } } // 3. render tags which require a dom element to be created or requires scanning DOM to determine duplicate for (const ctx of pending) { From 476a41035aaf81f21ea0643885a82e410b75b6e4 Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 16:36:00 +1100 Subject: [PATCH 21/78] perf(shared): combine filter in normaliseStyleClassProps --- packages/shared/src/normalise.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index a68ab5f8..c1987379 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -63,8 +63,7 @@ export function normaliseStyleClassProps(key: T, v: // finally, check we don't have spaces, we may need to split again return String((Array.isArray(v) ? v.join(sep) : v)) ?.split(sep) - .filter(c => c.trim()) - .filter(Boolean) + .filter(c => Boolean(c.trim())) .join(sep) } From 5158bb1be86af95c21b4090ee4ce5760f759fbae Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 17:19:49 +1100 Subject: [PATCH 22/78] perf(shared): optimize normaliseEntryTags for handle tag promises --- packages/shared/src/normalise.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index c1987379..f1986070 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -2,7 +2,7 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' import { type Thenable, thenable } from './thenable' import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags, asArray } from '.' -export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Thenable { +export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Thenable { return thenable( normaliseProps( // explicitly check for an object @@ -113,25 +113,29 @@ export function normaliseProps(props: T['props'], virtual?: b // support 1024 tag ids per entry (includes updates) export const TagEntityBits = 10 -export function normaliseEntryTags(e: HeadEntry): Promise { - const tagPromises: Promise[] = [] - Object.entries(e.resolvedInput as {}) +export function normaliseEntryTags(e: HeadEntry): Thenable { + const tagPromises = Object.entries(e.resolvedInput as object) .filter(([k, v]) => v !== undefined && ValidHeadTags.has(k)) - .forEach(([k, value]) => { + .flatMap(([k, value]) => { const v = asArray(value) + // @ts-expect-error untyped - tagPromises.push(...v.map(props => normaliseTag(k as keyof Head, props, e)).flat()) + return v.map(props => normaliseTag(k as keyof Head, props, e)) }) + + if (tagPromises.length === 0) { + return [] + } + return Promise.all(tagPromises) .then(headTags => ( headTags .flat() - .filter(Boolean) - .map((t: HeadTag, i) => { + .map((t, i) => { t._e = e._i e.mode && (t._m = e.mode) t._p = (e._i << TagEntityBits) + i return t - }) as unknown as HeadTag[] + }) )) } From 598719d38d68b9944b48b08ce7e982e4735c85f8 Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 17:41:53 +1100 Subject: [PATCH 23/78] refactor: use arrow function instead of regular anonymous --- packages/dom/src/domPlugin.ts | 2 +- packages/schema-org/src/plugin.ts | 6 +++--- packages/unhead/src/optionalPlugins/capoPlugin.ts | 2 +- packages/unhead/src/plugins/dedupe.ts | 4 ++-- packages/unhead/src/plugins/eventHandlers.ts | 4 ++-- packages/unhead/src/plugins/payload.ts | 2 +- packages/unhead/src/plugins/xss.ts | 2 +- packages/vue/src/Vue2ProvideUnheadPlugin.ts | 2 +- packages/vue/src/plugins/VueReactivityPlugin.ts | 2 +- packages/vue/src/vue2/index.ts | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/dom/src/domPlugin.ts b/packages/dom/src/domPlugin.ts index 63c37920..588126a0 100644 --- a/packages/dom/src/domPlugin.ts +++ b/packages/dom/src/domPlugin.ts @@ -14,7 +14,7 @@ export interface DomPluginOptions extends RenderDomHeadOptions { return { mode: 'client', hooks: { - 'entries:updated': function (head) { + 'entries:updated': (head) => { // async load the renderDOMHead function debouncedRenderDOMHead(head, options) }, diff --git a/packages/schema-org/src/plugin.ts b/packages/schema-org/src/plugin.ts index a833fd2e..72ba3575 100644 --- a/packages/schema-org/src/plugin.ts +++ b/packages/schema-org/src/plugin.ts @@ -33,10 +33,10 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial ({ key: 'schema-org', hooks: { - 'entries:resolve': function () { + 'entries:resolve': () => { graph = createSchemaOrgGraph() }, - 'tag:normalise': async function ({ tag }) { + 'tag:normalise': async ({ tag }) => { if (tag.key === 'schema-org-graph') { // this is a bit expensive, load in seperate chunk const { loadResolver } = await import('./resolver') @@ -75,7 +75,7 @@ export function SchemaOrgUnheadPlugin(config: MetaInput, meta: () => Partial { // find the schema.org node, should be a single instance for (const tag of ctx.tags) { if (tag.tag === 'script' && tag.key === 'schema-org-graph') { diff --git a/packages/unhead/src/optionalPlugins/capoPlugin.ts b/packages/unhead/src/optionalPlugins/capoPlugin.ts index 7c26a752..b6c6bf5d 100644 --- a/packages/unhead/src/optionalPlugins/capoPlugin.ts +++ b/packages/unhead/src/optionalPlugins/capoPlugin.ts @@ -5,7 +5,7 @@ const importRe = /@import/ /* @__NO_SIDE_EFFECTS__ */ export function CapoPlugin(options: { track?: boolean }) { return defineHeadPlugin({ hooks: { - 'tags:beforeResolve': function ({ tags }) { + 'tags:beforeResolve': ({ tags }) => { // handle 9 and down in capo for (const tag of tags) { if (tag.tagPosition && tag.tagPosition !== 'head') diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index aa56d842..97463607 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -5,7 +5,7 @@ const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) export default defineHeadPlugin({ hooks: { - 'tag:normalise': function ({ tag }) { + 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys ['hid', 'vmid', 'key'].forEach((key) => { if (tag.props[key]) { @@ -18,7 +18,7 @@ export default defineHeadPlugin({ if (dedupe) tag._d = dedupe }, - 'tags:resolve': function (ctx) { + 'tags:resolve': (ctx) => { // 1. Dedupe tags const deduping: Record = {} ctx.tags.forEach((tag) => { diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index ca729062..622bb973 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -9,7 +9,7 @@ const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) */ export default defineHeadPlugin(head => ({ hooks: { - 'tags:resolve': function (ctx) { + 'tags:resolve': (ctx) => { for (const tag of ctx.tags.filter(t => ValidEventTags.has(t.tag))) { // must be a valid tag Object.entries(tag.props) @@ -28,7 +28,7 @@ export default defineHeadPlugin(head => ({ tag.key = tag.key || hashCode(tag.props.src || tag.props.href) } }, - 'dom:renderTag': function ({ $el, tag }) { + 'dom:renderTag': ({ $el, tag }) => { // this is only handling SSR rendered tags with event handlers for (const k in (($el as HTMLScriptElement | undefined)?.dataset || {})) { if (!k.endsWith('fired')) { diff --git a/packages/unhead/src/plugins/payload.ts b/packages/unhead/src/plugins/payload.ts index 1d6e9dea..2684dda3 100644 --- a/packages/unhead/src/plugins/payload.ts +++ b/packages/unhead/src/plugins/payload.ts @@ -3,7 +3,7 @@ import { defineHeadPlugin } from '@unhead/shared' export default defineHeadPlugin({ mode: 'server', hooks: { - 'tags:resolve': function (ctx) { + 'tags:resolve': (ctx) => { const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record, title?: string } = {} ctx.tags.filter(tag => (tag.tag === 'titleTemplate' || tag.tag === 'templateParams' || tag.tag === 'title') && tag._m === 'server') .forEach((tag) => { diff --git a/packages/unhead/src/plugins/xss.ts b/packages/unhead/src/plugins/xss.ts index 0892aa39..852b60ef 100644 --- a/packages/unhead/src/plugins/xss.ts +++ b/packages/unhead/src/plugins/xss.ts @@ -2,7 +2,7 @@ import { defineHeadPlugin } from '@unhead/shared' export default defineHeadPlugin({ hooks: { - 'tags:afterResolve': function (ctx) { + 'tags:afterResolve': (ctx) => { for (const tag of ctx.tags) { if (typeof tag.innerHTML === 'string') { if (tag.innerHTML && (tag.props.type === 'application/ld+json' || tag.props.type === 'application/json')) { diff --git a/packages/vue/src/Vue2ProvideUnheadPlugin.ts b/packages/vue/src/Vue2ProvideUnheadPlugin.ts index ee065ae7..14a565d5 100644 --- a/packages/vue/src/Vue2ProvideUnheadPlugin.ts +++ b/packages/vue/src/Vue2ProvideUnheadPlugin.ts @@ -4,7 +4,7 @@ import { headSymbol } from './createHead' /** * @deprecated Import { UnheadPlugin } from `@unhead/vue/vue2` and use Vue.mixin(UnheadPlugin(head)) instead. */ -export const Vue2ProvideUnheadPlugin: Plugin = function (_Vue, head) { +export const Vue2ProvideUnheadPlugin: Plugin = (_Vue, head) => { // copied from https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/vue2-plugin.ts _Vue.mixin({ beforeCreate() { diff --git a/packages/vue/src/plugins/VueReactivityPlugin.ts b/packages/vue/src/plugins/VueReactivityPlugin.ts index 72736f48..95ecb080 100644 --- a/packages/vue/src/plugins/VueReactivityPlugin.ts +++ b/packages/vue/src/plugins/VueReactivityPlugin.ts @@ -3,7 +3,7 @@ import { resolveUnrefHeadInput } from '../utils' export default defineHeadPlugin({ hooks: { - 'entries:resolve': function (ctx) { + 'entries:resolve': (ctx) => { for (const entry of ctx.entries) entry.resolvedInput = resolveUnrefHeadInput(entry.input) }, diff --git a/packages/vue/src/vue2/index.ts b/packages/vue/src/vue2/index.ts index 6bcf2bb9..91b9f24a 100644 --- a/packages/vue/src/vue2/index.ts +++ b/packages/vue/src/vue2/index.ts @@ -4,7 +4,7 @@ import { headSymbol } from '../createHead' import { useHead } from '../composables/useHead' import { Vue3 } from '../env' -export const UnheadPlugin: Plugin = function (_Vue) { +export const UnheadPlugin: Plugin = (_Vue) => { // copied from https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/vue2-plugin.ts _Vue.mixin({ created() { From 1761dfbc34c3cd2e73122834afde120207966be5 Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 18:42:31 +1100 Subject: [PATCH 24/78] perf(shared): cache allowed meta properties for tagDedupeKey --- packages/shared/src/tagDedupeKey.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index 4af9276b..f5420729 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -1,6 +1,8 @@ import type { HeadTag } from '@unhead/schema' import { UniqueTags } from '.' +const allowedMetaProperties = ['name', 'property', 'http-equiv'] + export function tagDedupeKey(tag: T, fn?: (key: string) => boolean): string | false { const { props, tag: tagName } = tag // must only be a single base so we always dedupe @@ -11,21 +13,20 @@ export function tagDedupeKey(tag: T, fn?: (key: string) => bo if (tagName === 'link' && props.rel === 'canonical') return 'canonical' - // must only be a single charset if (props.charset) return 'charset' - const name = ['id'] - if (tagName === 'meta') - name.push('name', 'property', 'http-equiv') - for (const n of name) { + if (props.id && fn && fn(String(props.id))) { + return `${tagName}:id:${props.id}` + } + + for (const n of allowedMetaProperties) { // open graph props can have multiple tags with the same property if (props[n] !== undefined) { - const val = String(props[n]) - if (fn && !fn(val)) + if (fn && !fn(String(props[n]))) return false // for example: meta-name-description - return `${tagName}:${n}:${val}` + return `${tagName}:${n}:${props[n]}` } } return false From 067f9bd6dd4fe70bdb744b8bdfff0a680b432d37 Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 19:30:16 +1100 Subject: [PATCH 25/78] perf(shared): use concurrency chain instead of Promise.all in normaliseEntryTags --- packages/shared/src/normalise.ts | 39 +++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index f1986070..826d1070 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -127,15 +127,32 @@ export function normaliseEntryTags(e: HeadEntry): Th return [] } - return Promise.all(tagPromises) - .then(headTags => ( - headTags - .flat() - .map((t, i) => { - t._e = e._i - e.mode && (t._m = e.mode) - t._p = (e._i << TagEntityBits) + i - return t - }) - )) + const headTags: HeadTag[] = [] + + let thenableRet: Thenable + + for (const tagPromise of tagPromises) { + const res = thenable(tagPromise, (tags) => { + if (Array.isArray(tags)) { + headTags.push(...tags) + } + else { + headTags.push(tags) + } + }) + + if (res instanceof Promise) { + const prevThenableRet = thenableRet + thenableRet = res.then(() => prevThenableRet) + } + } + + return thenable(thenableRet, () => ( + headTags.map((t, i) => { + t._e = e._i + e.mode && (t._m = e.mode) + t._p = (e._i << TagEntityBits) + i + return t + }) + )) } From fde9f9ce7bf421ce25858dfdf27fb2514ef8c5e0 Mon Sep 17 00:00:00 2001 From: Negezor Date: Tue, 16 Jul 2024 20:57:49 +1100 Subject: [PATCH 26/78] perf(unhead): allocate once third party dedupe keys in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 97463607..580b125c 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -3,11 +3,13 @@ import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhe const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) +const thirdPartyDedupeKeys = ['hid', 'vmid', 'key'] + export default defineHeadPlugin({ hooks: { 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys - ['hid', 'vmid', 'key'].forEach((key) => { + thirdPartyDedupeKeys.forEach((key) => { if (tag.props[key]) { tag.key = tag.props[key] delete tag.props[key] From d345bf7f0bfe3ca3d10bb414683405ffff237801 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 00:03:35 +1100 Subject: [PATCH 27/78] refactor(shared): remove second parameter in tagDedupeKey --- packages/shared/src/tagDedupeKey.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/tagDedupeKey.ts b/packages/shared/src/tagDedupeKey.ts index f5420729..4b2791ee 100644 --- a/packages/shared/src/tagDedupeKey.ts +++ b/packages/shared/src/tagDedupeKey.ts @@ -3,7 +3,7 @@ import { UniqueTags } from '.' const allowedMetaProperties = ['name', 'property', 'http-equiv'] -export function tagDedupeKey(tag: T, fn?: (key: string) => boolean): string | false { +export function tagDedupeKey(tag: T): string | false { const { props, tag: tagName } = tag // must only be a single base so we always dedupe if (UniqueTags.has(tagName)) @@ -16,15 +16,13 @@ export function tagDedupeKey(tag: T, fn?: (key: string) => bo if (props.charset) return 'charset' - if (props.id && fn && fn(String(props.id))) { + if (props.id) { return `${tagName}:id:${props.id}` } for (const n of allowedMetaProperties) { // open graph props can have multiple tags with the same property if (props[n] !== undefined) { - if (fn && !fn(String(props[n]))) - return false // for example: meta-name-description return `${tagName}:${n}:${props[n]}` } From 028585be0fbabe4f8688ccb871027d09abe781ae Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 13:13:13 +1100 Subject: [PATCH 28/78] fix(shared): normalise should handle consistently --- packages/shared/src/normalise.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 826d1070..5323c122 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -129,22 +129,19 @@ export function normaliseEntryTags(e: HeadEntry): Th const headTags: HeadTag[] = [] - let thenableRet: Thenable + let thenableRet: Thenable = tagPromises.shift()! - for (const tagPromise of tagPromises) { - const res = thenable(tagPromise, (tags) => { + for (let i = 0, l = tagPromises.length + 1; i !== l; i += 1) { + thenableRet = thenable(thenableRet, (tags) => { if (Array.isArray(tags)) { headTags.push(...tags) } else { headTags.push(tags) } - }) - if (res instanceof Promise) { - const prevThenableRet = thenableRet - thenableRet = res.then(() => prevThenableRet) - } + return tagPromises[i] + }) as Thenable } return thenable(thenableRet, () => ( From b1caa7262530bba4d1633db0edccc20df2d32b30 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 14:41:41 +1100 Subject: [PATCH 29/78] perf(shared): reduce overhead from object.entries and map in normaliseEntryTags --- packages/shared/src/normalise.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 5323c122..4f134bc8 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -1,6 +1,6 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' import { type Thenable, thenable } from './thenable' -import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags, asArray } from '.' +import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from '.' export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Thenable { return thenable( @@ -114,14 +114,24 @@ export function normaliseProps(props: T['props'], virtual?: b export const TagEntityBits = 10 export function normaliseEntryTags(e: HeadEntry): Thenable { - const tagPromises = Object.entries(e.resolvedInput as object) - .filter(([k, v]) => v !== undefined && ValidHeadTags.has(k)) - .flatMap(([k, value]) => { - const v = asArray(value) - + const tagPromises: Thenable[] = [] + const input = e.resolvedInput as T + for (const k in input) { + if (!Object.prototype.hasOwnProperty.call(input, k)) { + continue + } + const v = input[k as keyof typeof input] + if (v === undefined || !ValidHeadTags.has(k)) { + continue + } + if (Array.isArray(v)) { // @ts-expect-error untyped - return v.map(props => normaliseTag(k as keyof Head, props, e)) - }) + tagPromises.push(...v.map(props => normaliseTag(k as keyof Head, props, e))) + continue + } + // @ts-expect-error untyped + tagPromises.push(normaliseTag(k as keyof Head, v, e)) + } if (tagPromises.length === 0) { return [] From fc32e5e0aa0c221ac6332d1339b2fde8585857bf Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 15:51:05 +1100 Subject: [PATCH 30/78] perf(shared): promise should be edge case in normaliseProps --- packages/shared/src/normalise.ts | 59 +++++++++++++++++--------------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 4f134bc8..aad00c73 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -67,10 +67,14 @@ export function normaliseStyleClassProps(key: T, v: .join(sep) } -export function normaliseProps(props: T['props'], virtual?: boolean): Thenable { - let thanableRet: Thenable - - for (const k of Object.keys(props)) { +export function nestedNormaliseProps( + props: T['props'], + virtual: boolean, + keys: (keyof T['props'])[], + startIndex: number, +): Thenable { + for (let i = startIndex; i < keys.length; i += 1) { + const k = keys[i] // handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes // class has special handling if (k === 'class' || k === 'style') { @@ -79,35 +83,36 @@ export function normaliseProps(props: T['props'], virtual?: b continue } - const result = thenable(props[k], (prop) => { - // @ts-expect-error untyped - props[k] = prop + // @ts-expect-error no reason for: The left-hand side of an 'instanceof' expression must be of type 'any', an object type or a type parameter. + if (props[k] instanceof Promise) { + return props[k].then((val) => { + props[k] = val - if (!virtual && !TagConfigKeys.has(k as string)) { - const v = String(props[k]) - // data keys get special treatment, we opt for more verbose syntax - const isDataKey = k.startsWith('data-') - if (v === 'true' || v === '') { + return nestedNormaliseProps(props, virtual, keys, i) + }) + } + + if (!virtual && !TagConfigKeys.has(k as string)) { + const v = String(props[k]) + // data keys get special treatment, we opt for more verbose syntax + const isDataKey = (k as string).startsWith('data-') + if (v === 'true' || v === '') { + // @ts-expect-error untyped + props[k] = isDataKey ? 'true' : true + } + else if (!props[k]) { + if (isDataKey && v === 'false') // @ts-expect-error untyped - props[k] = isDataKey ? 'true' : true - } - else if (!props[k]) { - if (isDataKey && v === 'false') - // @ts-expect-error untyped - props[k] = 'false' - else - delete props[k] - } + props[k] = 'false' + else + delete props[k] } - }) - - if (result instanceof Promise) { - const prevThenableRet = thanableRet - thanableRet = result.then(() => prevThenableRet) } } +} - return thenable(thanableRet, () => props) +export function normaliseProps(props: T['props'], virtual: boolean = false): Thenable { + return thenable(nestedNormaliseProps(props, virtual, Object.keys(props), 0), () => props) } // support 1024 tag ids per entry (includes updates) From 20ce4ae161036f932c7361b29df23fb18df50800 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 15:56:05 +1100 Subject: [PATCH 31/78] chore(shared): remove export from nestedNormaliseProps --- packages/shared/src/normalise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index aad00c73..929fee60 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -67,7 +67,7 @@ export function normaliseStyleClassProps(key: T, v: .join(sep) } -export function nestedNormaliseProps( +function nestedNormaliseProps( props: T['props'], virtual: boolean, keys: (keyof T['props'])[], From 0da77eb7a875ba164face7fd6e30c5dd64f51e7c Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 16:02:15 +1100 Subject: [PATCH 32/78] perf(shared): promise should be edge case in normaliseEntryTags --- packages/shared/src/normalise.ts | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 929fee60..267319a8 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -118,6 +118,27 @@ export function normaliseProps(props: T['props'], virtual: bo // support 1024 tag ids per entry (includes updates) export const TagEntityBits = 10 +function nestedNormaliseEntryTags(headTags: HeadTag[], resolvedTags: Thenable[], startIndex: number): Thenable { + for (let i = startIndex; resolvedTags.length < i; i += 1) { + const tags = resolvedTags[i] + + if (tags instanceof Promise) { + return tags.then((val) => { + resolvedTags[i] = val + + return nestedNormaliseEntryTags(headTags, resolvedTags, i) + }) + } + + if (Array.isArray(tags)) { + headTags.push(...tags) + } + else { + headTags.push(tags) + } + } +} + export function normaliseEntryTags(e: HeadEntry): Thenable { const tagPromises: Thenable[] = [] const input = e.resolvedInput as T @@ -144,22 +165,7 @@ export function normaliseEntryTags(e: HeadEntry): Th const headTags: HeadTag[] = [] - let thenableRet: Thenable = tagPromises.shift()! - - for (let i = 0, l = tagPromises.length + 1; i !== l; i += 1) { - thenableRet = thenable(thenableRet, (tags) => { - if (Array.isArray(tags)) { - headTags.push(...tags) - } - else { - headTags.push(tags) - } - - return tagPromises[i] - }) as Thenable - } - - return thenable(thenableRet, () => ( + return thenable(nestedNormaliseEntryTags(headTags, tagPromises, 0), () => ( headTags.map((t, i) => { t._e = e._i e.mode && (t._m = e.mode) From 1088358c7ecd9dac676fb1809e5379e5f282dd5d Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 16:10:24 +1100 Subject: [PATCH 33/78] fix(shared): switch condition in for i in nestedNormaliseEntryTags --- packages/shared/src/normalise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 267319a8..93aaea57 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -119,7 +119,7 @@ export function normaliseProps(props: T['props'], virtual: bo export const TagEntityBits = 10 function nestedNormaliseEntryTags(headTags: HeadTag[], resolvedTags: Thenable[], startIndex: number): Thenable { - for (let i = startIndex; resolvedTags.length < i; i += 1) { + for (let i = startIndex; i < resolvedTags.length; i += 1) { const tags = resolvedTags[i] if (tags instanceof Promise) { From 78e4f0edea3e8e6f2e4a71f49cb852cf3f5059a5 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 16:10:48 +1100 Subject: [PATCH 34/78] chore(shared): rename resolvedTags to tagPromises in nestedNormaliseEntryTags --- packages/shared/src/normalise.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 93aaea57..720f5948 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -118,15 +118,15 @@ export function normaliseProps(props: T['props'], virtual: bo // support 1024 tag ids per entry (includes updates) export const TagEntityBits = 10 -function nestedNormaliseEntryTags(headTags: HeadTag[], resolvedTags: Thenable[], startIndex: number): Thenable { - for (let i = startIndex; i < resolvedTags.length; i += 1) { - const tags = resolvedTags[i] +function nestedNormaliseEntryTags(headTags: HeadTag[], tagPromises: Thenable[], startIndex: number): Thenable { + for (let i = startIndex; i < tagPromises.length; i += 1) { + const tags = tagPromises[i] if (tags instanceof Promise) { return tags.then((val) => { - resolvedTags[i] = val + tagPromises[i] = val - return nestedNormaliseEntryTags(headTags, resolvedTags, i) + return nestedNormaliseEntryTags(headTags, tagPromises, i) }) } From afbbf93bdcb468afa6d74e20534b5fa1ec5a4c95 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 17:53:25 +1100 Subject: [PATCH 35/78] perf(shared): reduce overhead by using thenable in normaliseProps --- packages/shared/src/normalise.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 720f5948..7777551c 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -112,7 +112,13 @@ function nestedNormaliseProps( } export function normaliseProps(props: T['props'], virtual: boolean = false): Thenable { - return thenable(nestedNormaliseProps(props, virtual, Object.keys(props), 0), () => props) + const resolvedProps = nestedNormaliseProps(props, virtual, Object.keys(props), 0) + + if (resolvedProps instanceof Promise) { + return resolvedProps.then(() => props) + } + + return props } // support 1024 tag ids per entry (includes updates) From 5aa2c972298a876face020fcba65de849a7dd2b1 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 17:54:02 +1100 Subject: [PATCH 36/78] perf(shared): promise should be edge case in normaliseTag --- packages/shared/src/normalise.ts | 95 ++++++++++++++++---------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 7777551c..ef16395f 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -2,55 +2,56 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' import { type Thenable, thenable } from './thenable' import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from '.' -export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry): Thenable { - return thenable( - normaliseProps( - // explicitly check for an object - // @ts-expect-error untyped - typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) - ? { ...input } - : { [(tagName === 'script' || tagName === 'noscript' || tagName === 'style') ? 'innerHTML' : 'textContent']: input }, - (tagName === 'templateParams' || tagName === 'titleTemplate'), - ), - (props) => { - // input can be a function or an object, we need to clone it - const tag = { - tag: tagName, - props: props as T['props'], - } as T - // merge options from the entry - TagConfigKeys.forEach((k) => { +export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry, normalizedProps?: HeadTag['props']): Thenable { + const props = normalizedProps || normaliseProps( + // explicitly check for an object + // @ts-expect-error untyped + typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise) + ? { ...input } + : { [(tagName === 'script' || tagName === 'noscript' || tagName === 'style') ? 'innerHTML' : 'textContent']: input }, + (tagName === 'templateParams' || tagName === 'titleTemplate'), + ) + + if (props instanceof Promise) { + return props.then(val => normaliseTag(tagName, input, e, val)) + } + + // input can be a function or an object, we need to clone it + const tag = { + tag: tagName, + props: props as T['props'], + } as T + // merge options from the entry + TagConfigKeys.forEach((k) => { + // @ts-expect-error untyped + const val = tag.props[k] !== undefined ? tag.props[k] : e[k] + if (val !== undefined) { + // strip innerHTML and textContent for tags which don't support it= + if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { // @ts-expect-error untyped - const val = tag.props[k] !== undefined ? tag.props[k] : e[k] - if (val !== undefined) { - // strip innerHTML and textContent for tags which don't support it= - if (!(k === 'innerHTML' || k === 'textContent' || k === 'children') || TagsWithInnerContent.has(tag.tag)) { - // @ts-expect-error untyped - tag[k === 'children' ? 'innerHTML' : k] = val - } - delete tag.props[k] - } - }) - // TODO remove v2 - if (tag.props.body) { - // inserting dangerous javascript potentially - tag.tagPosition = 'bodyClose' - // clean up - delete tag.props.body - } - // shorthand for objects - if (tag.tag === 'script') { - if (typeof tag.innerHTML === 'object') { - tag.innerHTML = JSON.stringify(tag.innerHTML) - tag.props.type = tag.props.type || 'application/json' - } + tag[k === 'children' ? 'innerHTML' : k] = val } - // allow meta to be resolved into multiple tags if an array is provided on content - return Array.isArray(tag.props.content) - ? tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T)) - : tag - }, - ) + delete tag.props[k] + } + }) + // TODO remove v2 + if (tag.props.body) { + // inserting dangerous javascript potentially + tag.tagPosition = 'bodyClose' + // clean up + delete tag.props.body + } + // shorthand for objects + if (tag.tag === 'script') { + if (typeof tag.innerHTML === 'object') { + tag.innerHTML = JSON.stringify(tag.innerHTML) + tag.props.type = tag.props.type || 'application/json' + } + } + // allow meta to be resolved into multiple tags if an array is provided on content + return Array.isArray(tag.props.content) + ? tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T)) + : tag } export function normaliseStyleClassProps(key: T, v: Required['htmlAttrs']['class']> | Required['htmlAttrs']['style']>) { From 38efeb9ac4b384962d655d03a8c161f28c249849 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 19:02:53 +1100 Subject: [PATCH 37/78] perf(shared): use for of instead of forEach in normaliseTag --- packages/shared/src/normalise.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index ef16395f..008fc3a5 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -22,7 +22,7 @@ export function normaliseTag(tagName: T['tag'], input: HeadTa props: props as T['props'], } as T // merge options from the entry - TagConfigKeys.forEach((k) => { + for (const k of TagConfigKeys) { // @ts-expect-error untyped const val = tag.props[k] !== undefined ? tag.props[k] : e[k] if (val !== undefined) { @@ -33,7 +33,7 @@ export function normaliseTag(tagName: T['tag'], input: HeadTa } delete tag.props[k] } - }) + } // TODO remove v2 if (tag.props.body) { // inserting dangerous javascript potentially From ee3d19073ed40aba488659fa85c5f58cd93b2038 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 19:33:19 +1100 Subject: [PATCH 38/78] perf(unhead): move common props to top of module in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 580b125c..4a30a8ea 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -4,6 +4,7 @@ import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhe const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) const thirdPartyDedupeKeys = ['hid', 'vmid', 'key'] +const mergeCommonProps = ['class', 'style'] export default defineHeadPlugin({ hooks: { @@ -37,7 +38,7 @@ export default defineHeadPlugin({ if (strategy === 'merge') { const oldProps = dupedTag.props // apply oldProps to current props - ;['class', 'style'].forEach((key) => { + mergeCommonProps.forEach((key) => { if (oldProps[key]) { if (tag.props[key]) { // ensure style merge doesn't result in invalid css From 09a9d68b11b2a8aae3f5c942298ca9688bd0bdab Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 19:42:30 +1100 Subject: [PATCH 39/78] perf(unhead): use for in instead of object values in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 4a30a8ea..03b93fe6 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -84,7 +84,11 @@ export default defineHeadPlugin({ deduping[dedupeKey] = tag }) const newTags: HeadTag[] = [] - Object.values(deduping).forEach((tag) => { + for (const key in deduping) { + if (!Object.prototype.hasOwnProperty.call(deduping, key)) { + continue + } + const tag = deduping[key] // @ts-expect-error runtime type const dupes = tag._duped // @ts-expect-error runtime type @@ -93,7 +97,7 @@ export default defineHeadPlugin({ // add the duped tags to the new tags if (dupes) newTags.push(...dupes) - }) + } ctx.tags = newTags // now filter out invalid meta // TODO separate plugin From 5478958c5f3c9210727efa51eb8148df4c599adc Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 19:51:28 +1100 Subject: [PATCH 40/78] perf(unhead): use for of instead of forEach in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 03b93fe6..1a11ff21 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -10,12 +10,12 @@ export default defineHeadPlugin({ hooks: { 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys - thirdPartyDedupeKeys.forEach((key) => { + for (const key of thirdPartyDedupeKeys) { if (tag.props[key]) { tag.key = tag.props[key] delete tag.props[key] } - }) + } const generatedKey = tagDedupeKey(tag) const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false) if (dedupe) @@ -24,7 +24,7 @@ export default defineHeadPlugin({ 'tags:resolve': (ctx) => { // 1. Dedupe tags const deduping: Record = {} - ctx.tags.forEach((tag) => { + for (const tag of ctx.tags) { // need a seperate dedupe key other than _d const dedupeKey = (tag.key ? `${tag.tag}:${tag.key}` : tag._d) || tag._p! const dupedTag: HeadTag = deduping[dedupeKey] @@ -38,7 +38,7 @@ export default defineHeadPlugin({ if (strategy === 'merge') { const oldProps = dupedTag.props // apply oldProps to current props - mergeCommonProps.forEach((key) => { + for (const key of mergeCommonProps) { if (oldProps[key]) { if (tag.props[key]) { // ensure style merge doesn't result in invalid css @@ -51,12 +51,12 @@ export default defineHeadPlugin({ tag.props[key] = oldProps[key] } } - }) + } deduping[dedupeKey].props = { ...oldProps, ...tag.props, } - return + continue } else if (tag._e === dupedTag._e) { // add the duped tag to the current tag @@ -66,11 +66,11 @@ export default defineHeadPlugin({ tag._d = `${dupedTag._d}:${dupedTag._duped.length + 1}` // @ts-expect-error runtime type dupedTag._duped.push(tag) - return + continue } else if (tagWeight(tag) > tagWeight(dupedTag)) { // check tag weights - return + continue } } const propCount = Object.keys(tag.props).length + (tag.innerHTML ? 1 : 0) + (tag.textContent ? 1 : 0) @@ -78,11 +78,11 @@ export default defineHeadPlugin({ if (HasElementTags.has(tag.tag) && propCount === 0) { // find the tag with the same key delete deduping[dedupeKey] - return + continue } // make sure the tag we're replacing has a lower tag weight deduping[dedupeKey] = tag - }) + } const newTags: HeadTag[] = [] for (const key in deduping) { if (!Object.prototype.hasOwnProperty.call(deduping, key)) { From 0ea560f4326b6be868d08d4b5f7dcdce5f917f09 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 20:10:58 +1100 Subject: [PATCH 41/78] perf(unhead): use for in instead of object entries in eventHandler plugin is a double acceleration of the plugin --- packages/unhead/src/plugins/eventHandlers.ts | 52 ++++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index 622bb973..4581cdcf 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -10,22 +10,44 @@ const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': (ctx) => { - for (const tag of ctx.tags.filter(t => ValidEventTags.has(t.tag))) { - // must be a valid tag - Object.entries(tag.props) - .forEach(([key, value]) => { - if (key.startsWith('on') && typeof value === 'function') { - // insert a inline script to set the status of onload and onerror - if (head.ssr && NetworkEvents.has(key)) - tag.props[key] = `this.dataset.${key}fired = true` - - else delete tag.props[key] - tag._eventHandlers = tag._eventHandlers || {} - tag._eventHandlers![key] = value - } - }) - if (head.ssr && tag._eventHandlers && (tag.props.src || tag.props.href)) + for (const tag of ctx.tags) { + if (!ValidEventTags.has(tag.tag)) { + continue + } + + const props = tag.props + + 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 (head.ssr && NetworkEvents.has(key)) { + props[key] = `this.dataset.${key}fired = true` + } + else { + 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 }) => { From 4a66828dc80a6c5b424f26e03ceec774ee0c7c2a Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 20:15:10 +1100 Subject: [PATCH 42/78] perf(unhead): use slice & substring instead of replace in eventHandler --- packages/unhead/src/plugins/eventHandlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index 4581cdcf..24a881d3 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -57,13 +57,15 @@ export default defineHeadPlugin(head => ({ continue } - const ek = k.replace('fired', '') + // onloadfired -> onload + const ek = k.slice(0, -5) if (!NetworkEvents.has(ek)) { continue } - tag._eventHandlers?.[ek]?.call($el, new Event(ek.replace('on', ''))) + // onload -> load + tag._eventHandlers?.[ek]?.call($el, new Event(ek.substring(2))) } }, }, From 3b3481b6e55923f2398f95d98ba3e25ad4672046 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 20:19:38 +1100 Subject: [PATCH 43/78] perf(unhead): move filter into loop body in sort plugin --- packages/unhead/src/plugins/sort.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index 97dc907d..315f7a8b 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -8,7 +8,11 @@ export default defineHeadPlugin({ // 2a. Sort based on priority // now we need to check render priority for each before: rule and use the dedupe key index for (const { prefix, offset } of SortModifiers) { - for (const tag of ctx.tags.filter(tag => typeof tag.tagPriority === 'string' && tag.tagPriority!.startsWith(prefix))) { + for (const tag of ctx.tags) { + if (typeof tag.tagPriority !== 'string' || !tag.tagPriority!.startsWith(prefix)) { + continue + } + const position = tagPositionForKey( (tag.tagPriority as string).replace(prefix, ''), ) From 1e64fd82d538641bf5156d91ba178b2a639cba34 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 20:24:48 +1100 Subject: [PATCH 44/78] perf(unhead): use substring instead of replace in sort plugin --- packages/unhead/src/plugins/sort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index 315f7a8b..276d121a 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -14,7 +14,7 @@ export default defineHeadPlugin({ } const position = tagPositionForKey( - (tag.tagPriority as string).replace(prefix, ''), + (tag.tagPriority as string).substring(prefix.length), ) if (position !== undefined) tag._p = position + offset From a53e10f5ff064ca648f0627bb28962983438398a Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 20:39:37 +1100 Subject: [PATCH 45/78] perf(unhead): swap the loops in the sorting plugin --- packages/unhead/src/plugins/sort.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index 276d121a..b03f3655 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -3,21 +3,26 @@ import { SortModifiers, defineHeadPlugin, tagWeight } from '@unhead/shared' export default defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { - const tagPositionForKey = (key: string) => ctx.tags.find(tag => tag._d === key)?._p - // 2a. Sort based on priority // now we need to check render priority for each before: rule and use the dedupe key index - for (const { prefix, offset } of SortModifiers) { - for (const tag of ctx.tags) { - if (typeof tag.tagPriority !== 'string' || !tag.tagPriority!.startsWith(prefix)) { + for (const tag of ctx.tags) { + if (typeof tag.tagPriority !== 'string') { + continue + } + + for (const { prefix, offset } of SortModifiers) { + if (!tag.tagPriority.startsWith(prefix)) { continue } - const position = tagPositionForKey( - (tag.tagPriority as string).substring(prefix.length), - ) - if (position !== undefined) + const key = (tag.tagPriority as string).substring(prefix.length) + + const position = ctx.tags.find(tag => tag._d === key)?._p + + if (position !== undefined) { tag._p = position + offset + break + } } } From 95a552150d988e77411676fb37038a8ba2afbb3d Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 21:41:42 +1100 Subject: [PATCH 46/78] perf(shared): simplify complexity in tagWeight --- packages/shared/src/sort.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/sort.ts b/packages/shared/src/sort.ts index 93dbc1bb..65c5f3cd 100644 --- a/packages/shared/src/sort.ts +++ b/packages/shared/src/sort.ts @@ -14,18 +14,18 @@ export const TAG_ALIASES = { } as const export function tagWeight(tag: T) { - let weight = 100 const priority = tag.tagPriority if (typeof priority === 'number') return priority + let weight = 100 if (tag.tag === 'meta') { // CSP needs to be as it effects the loading of assets if (tag.props['http-equiv'] === 'content-security-policy') weight = -30 // charset must come early in case there's non-utf8 characters in the HTML document - if (tag.props.charset) + else if (tag.props.charset) weight = -20 - if (tag.props.name === 'viewport') + else if (tag.props.name === 'viewport') weight = -15 } else if (tag.tag === 'link' && tag.props.rel === 'preconnect') { @@ -35,9 +35,9 @@ export function tagWeight(tag: T) { else if (tag.tag in TAG_WEIGHTS) { weight = TAG_WEIGHTS[tag.tag as keyof typeof TAG_WEIGHTS] } - if (typeof priority === 'string' && priority in TAG_ALIASES) { - // @ts-expect-error untyped - return weight + TAG_ALIASES[priority] + if (priority && priority in TAG_ALIASES) { + // @ts-expect-e+rror untyped + return weight + TAG_ALIASES[priority as keyof typeof TAG_ALIASES] } return weight } From f90c77ab085b9f2e4c7ef2d8de3a2f958bd74e62 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 21:44:58 +1100 Subject: [PATCH 47/78] perf(unhead): combine sorts in sort plugin --- packages/unhead/src/plugins/sort.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index b03f3655..b98fc62e 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -26,11 +26,21 @@ export default defineHeadPlugin({ } } - ctx.tags - // 2b. sort tags in their natural order - .sort((a, b) => a._p! - b._p!) + ctx.tags.sort((a, b) => { + const aWeight = tagWeight(a) + const bWeight = tagWeight(b) + // 2c. sort based on critical tags - .sort((a, b) => tagWeight(a) - tagWeight(b)) + if (aWeight < bWeight) { + return -1 + } + else if (aWeight > bWeight) { + return 1 + } + + // 2b. sort tags in their natural order + return a._p! - b._p! + }) }, }, }) From cb268357db55fbb8bf5682946dcd64b275645817 Mon Sep 17 00:00:00 2001 From: Negezor Date: Wed, 17 Jul 2024 21:56:18 +1100 Subject: [PATCH 48/78] perf(unhead): reduce complexity to search in templateParams plugin --- packages/unhead/src/plugins/templateParams.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index 11c431d1..b3758657 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -11,16 +11,18 @@ export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': (ctx) => { const { tags } = ctx - // find templateParams - const title = tags.find(tag => tag.tag === 'title')?.textContent - const idx = tags.findIndex(tag => tag.tag === 'templateParams') // we always process params so we can substitute the title - const params = idx !== -1 ? tags[idx].props as unknown as TemplateParams : {} + const params = (tags.find(tag => tag.tag === 'templateParams')?.props || {}) as TemplateParams // ensure a separator exists const sep = params.separator || '|' delete params.separator // pre-process title - params.pageTitle = processTemplateParams(params.pageTitle as string || title || '', params, sep) + params.pageTitle = processTemplateParams( + // find templateParams + params.pageTitle as string || tags.find(tag => tag.tag === 'title')?.textContent || '', + params, + sep, + ) for (const tag of tags.filter(t => t.processTemplateParams !== false)) { // @ts-expect-error untyped const v = SupportedAttrs[tag.tag] From ce653f88d55f4abb3e014778c14e7bfb36cf7bbf Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 01:25:34 +1100 Subject: [PATCH 49/78] perf(unhead): move filter into loop body in templateParams plugin --- packages/unhead/src/plugins/templateParams.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index b3758657..5611e3f1 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -23,7 +23,10 @@ export default defineHeadPlugin(head => ({ params, sep, ) - for (const tag of tags.filter(t => t.processTemplateParams !== false)) { + for (const tag of tags) { + if (tag.processTemplateParams === false) { + continue + } // @ts-expect-error untyped const v = SupportedAttrs[tag.tag] if (v && typeof tag.props[v] === 'string') { From e6b5d1aea01d2d8e7ad71793f7543e5a5a549c03 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 02:47:10 +1100 Subject: [PATCH 50/78] perf(unhead): find and remove once templateParams tag in templateParams plugin --- packages/unhead/src/plugins/templateParams.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index 5611e3f1..45becffb 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -11,8 +11,20 @@ export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': (ctx) => { const { tags } = ctx + let templateParams: TemplateParams | undefined + for (let i = 0; i < tags.length; i += 1) { + const tag = tags[i] + + if (tag.tag !== 'templateParams') { + continue + } + + templateParams = ctx.tags.splice(i, 1)[0].props + + i -= 1 + } // we always process params so we can substitute the title - const params = (tags.find(tag => tag.tag === 'templateParams')?.props || {}) as TemplateParams + const params = (templateParams || {}) as TemplateParams // ensure a separator exists const sep = params.separator || '|' delete params.separator @@ -45,7 +57,6 @@ export default defineHeadPlugin(head => ({ // resolved template params head._templateParams = params head._separator = sep - ctx.tags = tags.filter(tag => tag.tag !== 'templateParams') }, }, })) From f120960873ec821ac99c770d473893ff62fa2940 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 02:54:32 +1100 Subject: [PATCH 51/78] perf(unhead): improve contentAttrs handle in templateParams plugin --- packages/unhead/src/plugins/templateParams.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/unhead/src/plugins/templateParams.ts b/packages/unhead/src/plugins/templateParams.ts index 45becffb..b6aa0d7a 100644 --- a/packages/unhead/src/plugins/templateParams.ts +++ b/packages/unhead/src/plugins/templateParams.ts @@ -7,6 +7,8 @@ const SupportedAttrs = { htmlAttrs: 'lang', } as const +const contentAttrs = ['innerHTML', 'textContent'] + export default defineHeadPlugin(head => ({ hooks: { 'tags:resolve': (ctx) => { @@ -45,13 +47,13 @@ export default defineHeadPlugin(head => ({ tag.props[v] = processTemplateParams(tag.props[v], params, sep) } // everything else requires explicit opt-in - else if (tag.processTemplateParams === true || tag.tag === 'titleTemplate' || tag.tag === 'title') { - ['innerHTML', 'textContent'].forEach((p) => { + else if (tag.processTemplateParams || tag.tag === 'titleTemplate' || tag.tag === 'title') { + for (const p of contentAttrs) { // @ts-expect-error untyped if (typeof tag[p] === 'string') // @ts-expect-error untyped tag[p] = processTemplateParams(tag[p], params, sep) - }) + } } } // resolved template params From cbae67e04b36a454b1eb2a7feea163fc36f9b918 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 03:03:36 +1100 Subject: [PATCH 52/78] perf(unhead): speed up payload plugin using one loop --- packages/unhead/src/plugins/payload.ts | 32 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/unhead/src/plugins/payload.ts b/packages/unhead/src/plugins/payload.ts index 2684dda3..f212fa08 100644 --- a/packages/unhead/src/plugins/payload.ts +++ b/packages/unhead/src/plugins/payload.ts @@ -5,17 +5,29 @@ export default defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record, title?: string } = {} - ctx.tags.filter(tag => (tag.tag === 'titleTemplate' || tag.tag === 'templateParams' || tag.tag === 'title') && tag._m === 'server') - .forEach((tag) => { - // @ts-expect-error untyped - payload[tag.tag] = tag.tag.startsWith('title') ? tag.textContent : tag.props + + let hasPayload = false + + for (const tag of ctx.tags) { + if (tag._m !== 'server' || (tag.tag !== 'titleTemplate' && tag.tag !== 'templateParams' && tag.tag !== 'title')) { + continue + } + + // @ts-expect-error untyped + payload[tag.tag] = tag.tag === 'title' || tag.tag === 'titleTemplate' + ? tag.textContent + : tag.props + hasPayload = true + } + + if (hasPayload) { + // add tag for rendering + ctx.tags.push({ + tag: 'script', + innerHTML: JSON.stringify(payload), + props: { id: 'unhead:payload', type: 'application/json' }, }) - // add tag for rendering - Object.keys(payload).length && ctx.tags.push({ - tag: 'script', - innerHTML: JSON.stringify(payload), - props: { id: 'unhead:payload', type: 'application/json' }, - }) + } }, }, }) From 18bf2882b98f060931f03773664262ec16f645d1 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 03:29:17 +1100 Subject: [PATCH 53/78] perf(unhead): compute props keys after static in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 1a11ff21..34c18be6 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -73,9 +73,10 @@ export default defineHeadPlugin({ continue } } - const propCount = Object.keys(tag.props).length + (tag.innerHTML ? 1 : 0) + (tag.textContent ? 1 : 0) + // PERF: compute the number of props keys after static check + const hasProps = tag.innerHTML || tag.textContent || Object.keys(tag.props).length !== 0 // if the new tag does not have any props, we're trying to remove the duped tag from the DOM - if (HasElementTags.has(tag.tag) && propCount === 0) { + if (HasElementTags.has(tag.tag) && !hasProps) { // find the tag with the same key delete deduping[dedupeKey] continue From b750c3243924902e3d60e38e9092e66d114fca79 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 04:26:06 +1100 Subject: [PATCH 54/78] perf(unhead): use splice instead of delete index + filter in titleTemplate plugin --- packages/unhead/src/plugins/titleTemplate.ts | 49 ++++++++++++-------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/unhead/src/plugins/titleTemplate.ts b/packages/unhead/src/plugins/titleTemplate.ts index 6a84dff5..235111a6 100644 --- a/packages/unhead/src/plugins/titleTemplate.ts +++ b/packages/unhead/src/plugins/titleTemplate.ts @@ -1,41 +1,52 @@ +import type { HeadTag } from '@unhead/schema' import { defineHeadPlugin, resolveTitleTemplate } from '@unhead/shared' export default defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { const { tags } = ctx - let titleTemplateIdx = tags.findIndex(i => i.tag === 'titleTemplate') - const titleIdx = tags.findIndex(i => i.tag === 'title') - if (titleIdx !== -1 && titleTemplateIdx !== -1) { + + let titleTag: HeadTag | undefined + let titleTemplateTag: HeadTag | undefined + for (let i = 0; i < tags.length; i += 1) { + const tag = tags[i] + + if (tag.tag === 'title') { + titleTag = tag + } + else if (tag.tag === 'titleTemplate') { + titleTemplateTag = tag + } + } + + if (titleTemplateTag && titleTag) { const newTitle = resolveTitleTemplate( - tags[titleTemplateIdx].textContent!, - tags[titleIdx].textContent, + titleTemplateTag.textContent!, + titleTag.textContent, ) + if (newTitle !== null) { - tags[titleIdx].textContent = newTitle || tags[titleIdx].textContent + titleTag.textContent = newTitle || titleTag.textContent } else { - // remove the title tag - delete tags[titleIdx] + ctx.tags.splice(ctx.tags.indexOf(titleTag), 1) } } - // titleTemplate is set but title is not set, convert to a title - else if (titleTemplateIdx !== -1) { + else if (titleTemplateTag) { const newTitle = resolveTitleTemplate( - tags[titleTemplateIdx].textContent!, + titleTemplateTag.textContent!, ) + if (newTitle !== null) { - tags[titleTemplateIdx].textContent = newTitle - tags[titleTemplateIdx].tag = 'title' - titleTemplateIdx = -1 + titleTemplateTag.textContent = newTitle + titleTemplateTag.tag = 'title' + titleTemplateTag = undefined } } - if (titleTemplateIdx !== -1) { - // remove the titleTemplate tag - delete tags[titleTemplateIdx] - } - ctx.tags = tags.filter(Boolean) + if (titleTemplateTag) { + ctx.tags.splice(ctx.tags.indexOf(titleTemplateTag), 1) + } }, }, }) From 667e15e7e95a8db45405ab457bc0496025c9bb84 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 04:35:02 +1100 Subject: [PATCH 55/78] perf(unhead): first check hasProps in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 34c18be6..a6aa4ecf 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -76,7 +76,7 @@ export default defineHeadPlugin({ // PERF: compute the number of props keys after static check const hasProps = tag.innerHTML || tag.textContent || Object.keys(tag.props).length !== 0 // if the new tag does not have any props, we're trying to remove the duped tag from the DOM - if (HasElementTags.has(tag.tag) && !hasProps) { + if (!hasProps && HasElementTags.has(tag.tag)) { // find the tag with the same key delete deduping[dedupeKey] continue From e74bbc553a1cb4c04b2725a3bc653bb7746a3c5a Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 06:01:34 +1100 Subject: [PATCH 56/78] perf(unhead): handle classes & styles without loop in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index a6aa4ecf..297533d6 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -4,7 +4,6 @@ import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhe const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) const thirdPartyDedupeKeys = ['hid', 'vmid', 'key'] -const mergeCommonProps = ['class', 'style'] export default defineHeadPlugin({ hooks: { @@ -37,21 +36,21 @@ export default defineHeadPlugin({ if (strategy === 'merge') { const oldProps = dupedTag.props - // apply oldProps to current props - for (const key of mergeCommonProps) { - if (oldProps[key]) { - if (tag.props[key]) { - // ensure style merge doesn't result in invalid css - if (key === 'style' && !oldProps[key].endsWith(';')) - oldProps[key] += ';' - - tag.props[key] = `${oldProps[key]} ${tag.props[key]}` - } - else { - tag.props[key] = oldProps[key] - } + // special handle for styles + if (oldProps.style && tag.props.style) { + if (oldProps.style[oldProps.style.length - 1] !== ';') { + oldProps.style += ';' } + tag.props.style = `${oldProps.style} ${tag.props.style}` + } + // special handle for classes + if (oldProps.class && tag.props.class) { + tag.props.class = `${oldProps.class} ${tag.props.class}` } + else if (oldProps.class) { + tag.props.class = oldProps.class + } + // apply oldProps to current props deduping[dedupeKey].props = { ...oldProps, ...tag.props, From 04760894e7fd68267b537ee96050c73f4b527d63 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 06:20:07 +1100 Subject: [PATCH 57/78] perf(unhead): use object with null prototype in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 297533d6..684fc05e 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -22,7 +22,7 @@ export default defineHeadPlugin({ }, 'tags:resolve': (ctx) => { // 1. Dedupe tags - const deduping: Record = {} + const deduping: Record = Object.create(null) for (const tag of ctx.tags) { // need a seperate dedupe key other than _d const dedupeKey = (tag.key ? `${tag.tag}:${tag.key}` : tag._d) || tag._p! @@ -85,9 +85,6 @@ export default defineHeadPlugin({ } const newTags: HeadTag[] = [] for (const key in deduping) { - if (!Object.prototype.hasOwnProperty.call(deduping, key)) { - continue - } const tag = deduping[key] // @ts-expect-error runtime type const dupes = tag._duped From d1ccd897a7f2933db7b11a4892e22df2b386a180 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 06:46:30 +1100 Subject: [PATCH 58/78] perf(unhead): use for of loop instead of map for patch entry --- packages/unhead/src/createHead.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/unhead/src/createHead.ts b/packages/unhead/src/createHead.ts index 26ce62fb..7ba11c95 100644 --- a/packages/unhead/src/createHead.ts +++ b/packages/unhead/src/createHead.ts @@ -96,13 +96,12 @@ export function createHeadCore(options: CreateHeadOptions = }, // a patch is the same as creating a new entry, just a nice DX patch(input) { - entries = entries.map((e) => { + for (const e of entries) { if (e._i === entry._i) { // bit hacky syncing e.input = entry.input = input } - return e - }) + } updated() }, } From e29952f1c4809b39996cd733599bf9f1b3d44696 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 07:05:41 +1100 Subject: [PATCH 59/78] perf(dom): use for in loop for props instead of Object.entries in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index f32756dd..56c257bb 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -100,13 +100,21 @@ export async function renderDOMHead>(head: T, options: Ren $el.setAttribute(`data-${k}`, '') } } - Object.entries(tag.props).forEach(([k, value]) => { + for (const k in tag.props) { + if (!Object.prototype.hasOwnProperty.call(tag.props, k)) { + continue + } + const value = tag.props[k] + const ck = `attr:${k}` // class attributes have their own side effects to allow for merging if (k === 'class') { + if (!value) { + continue + } // if the user is providing an empty string, then it's removing the class // the side effect clean up should remove it - for (const c of (value || '').split(' ')) { + for (const c of value.split(' ')) { if (!c) { continue } @@ -116,8 +124,11 @@ export async function renderDOMHead>(head: T, options: Ren } } else if (k === 'style') { + if (!value) { + continue + } // style attributes have their own side effects to allow for merging - for (const c of (value || '').split(';')) { + for (const c of value.split(';')) { if (!c) { continue } @@ -135,7 +146,7 @@ export async function renderDOMHead>(head: T, options: Ren $el.getAttribute(k) !== value && $el.setAttribute(k, (value as string | boolean) === true ? '' : String(value)) isAttrTag && track(id, ck, () => $el.removeAttribute(k)) } - }) + } } const pending: DomRenderTagContext[] = [] From 9214fe78073f78f560c21c894d3b78712550c047 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 07:15:10 +1100 Subject: [PATCH 60/78] perf(dom): use for in loop for handle _eventHandlers in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 56c257bb..dc8000f2 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -92,12 +92,22 @@ export async function renderDOMHead>(head: T, options: Ren delete state.elMap[id] }) } - // we need to attach event listeners as they can have side effects such as onload - for (const [k, value] of Object.entries(tag._eventHandlers || {})) { - if ($el.getAttribute(`data-${k}`) !== '') { - // avoid overriding - (tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener(k.replace('on', ''), value.bind($el)) - $el.setAttribute(`data-${k}`, '') + if (tag._eventHandlers) { + // we need to attach event listeners as they can have side effects such as onload + for (const k in tag._eventHandlers) { + if (!Object.prototype.hasOwnProperty.call(tag._eventHandlers, k)) { + continue + } + + if ($el.getAttribute(`data-${k}`) !== '') { + // avoid overriding + (tag!.tag === 'bodyAttrs' ? dom!.defaultView! : $el).addEventListener( + // onload -> load + k.substring(2), + tag._eventHandlers[k].bind($el), + ) + $el.setAttribute(`data-${k}`, '') + } } } for (const k in tag.props) { @@ -115,9 +125,6 @@ export async function renderDOMHead>(head: T, options: Ren // if the user is providing an empty string, then it's removing the class // the side effect clean up should remove it for (const c of value.split(' ')) { - if (!c) { - continue - } // always clear side effects isAttrTag && track(id, `${ck}:${c}`, () => $el.classList.remove(c)) !$el.classList.contains(c) && $el.classList.add(c) @@ -129,9 +136,6 @@ export async function renderDOMHead>(head: T, options: Ren } // style attributes have their own side effects to allow for merging for (const c of value.split(';')) { - if (!c) { - continue - } const propIndex = c.indexOf(':') const k = c.substring(0, propIndex).trim() const v = c.substring(propIndex + 1).trim() From 32851ff191c550a3011df273c842ffd0178c328f Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 07:17:41 +1100 Subject: [PATCH 61/78] perf(dom): clear side effects in for in loop in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index dc8000f2..6c3a2e11 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -197,7 +197,9 @@ export async function renderDOMHead>(head: T, options: Ren frag.bodyClose && dom.body.appendChild(frag.bodyClose) // clear all side effects still pending - Object.values(state.pendingSideEffects).forEach(fn => fn()) + for (const k in state.pendingSideEffects) { + state.pendingSideEffects[k]() + } head._dom = state head.dirty = false await head.hooks.callHook('dom:rendered', { renders: tags }) From 4193b984c431a77d038e87ddb95b48a247eeea30 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 07:27:37 +1100 Subject: [PATCH 62/78] perf(dom): replace loop with a direct property check for textContent and innerHTML --- packages/dom/src/renderDOMHead.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 6c3a2e11..8d9b307f 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -82,10 +82,12 @@ export async function renderDOMHead>(head: T, options: Ren const isAttrTag = tag.tag.endsWith('Attrs') state.elMap[id] = $el if (!isAttrTag) { - ;['textContent', 'innerHTML'].forEach((k) => { - // @ts-expect-error unkeyed - tag[k] && tag[k] !== $el[k] && ($el[k] = tag[k]) - }) + if (tag.textContent && tag.textContent !== $el.textContent) { + $el.textContent = tag.textContent + } + if (tag.innerHTML && tag.innerHTML !== $el.innerHTML) { + $el.innerHTML = tag.innerHTML + } track(id, 'el', () => { // the element may have been removed by a duplicate tag or something out of our control state.elMap[id]?.remove() From f391c98ecc1cb231e05746d3df66d54c41408348 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 07:36:56 +1100 Subject: [PATCH 63/78] perf(dom): remove cast to array HTMLCollection in renderDOMHead --- packages/dom/src/renderDOMHead.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 8d9b307f..17efdae9 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -43,7 +43,7 @@ export async function renderDOMHead>(head: T, options: Ren for (const key of ['body', 'head']) { const children = dom[key as 'head' | 'body']?.children const tags: HeadTag[] = [] - for (const c of [...children]) { + for (const c of children) { const tag = c.tagName.toLowerCase() as HeadTag['tag'] if (!HasElementTags.has(tag)) { continue From fd0c2613331312c518dfa8966d273c4f303bc184 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 08:20:49 +1100 Subject: [PATCH 64/78] perf(unhead): speed up hashTag using for in loop & early returns --- packages/shared/src/hashCode.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/hashCode.ts b/packages/shared/src/hashCode.ts index 99c76ed9..7ea1890c 100644 --- a/packages/shared/src/hashCode.ts +++ b/packages/shared/src/hashCode.ts @@ -11,5 +11,19 @@ export function hashCode(s: string) { } export function hashTag(tag: HeadTag) { - return tag._h || hashCode(tag._d ? tag._d : `${tag.tag}:${tag.textContent || tag.innerHTML || ''}:${Object.entries(tag.props).map(([key, value]) => `${key}:${String(value)}`).join(',')}`) + if (tag._h) { + return tag._h + } + + if (tag._d) { + return hashCode(tag._d) + } + + let content = `${tag.tag}:${tag.textContent || tag.innerHTML || ''}:` + + for (const key in tag.props) { + content += `${key}:${tag.props[key]},` + } + + return hashCode(content) } From ec0f70f61c92e8a28a7e4b892395ec9db8f5697a Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 08:46:08 +1100 Subject: [PATCH 65/78] perf(vue): reduce overhead from resolveUnrefHeadInput --- packages/vue/src/utils.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/vue/src/utils.ts b/packages/vue/src/utils.ts index 2cf508ef..31c2d419 100644 --- a/packages/vue/src/utils.ts +++ b/packages/vue/src/utils.ts @@ -5,7 +5,7 @@ function resolveUnref(r: any) { return typeof r === 'function' ? r() : unref(r) } -export function resolveUnrefHeadInput(ref: any, lastKey: string | number = ''): any { +export function resolveUnrefHeadInput(ref: any): any { // allow promises to bubble through if (ref instanceof Promise) return ref @@ -14,17 +14,27 @@ export function resolveUnrefHeadInput(ref: any, lastKey: string | number = ''): return root if (Array.isArray(root)) - return root.map(r => resolveUnrefHeadInput(r, lastKey)) + return root.map(r => resolveUnrefHeadInput(r)) if (typeof root === 'object') { - return Object.fromEntries( - Object.entries(root).map(([k, v]) => { - // title template and raw dom events should stay functions, we support a ref'd string though - if (k === 'titleTemplate' || k.startsWith('on')) - return [k, unref(v)] - return [k, resolveUnrefHeadInput(v, k)] - }), - ) + const resolved: Record = {} + + for (const k in root) { + if (!Object.prototype.hasOwnProperty.call(root, k)) { + continue + } + + if (k === 'titleTemplate' || (k[0] === 'o' && k[1] === 'n')) { + resolved[k] = unref(root[k]) + + continue + } + + resolved[k] = resolveUnrefHeadInput(root[k]) + } + + return resolved } + return root } From 4dbbd5961de893f6972738fcbe0e187e6ee28944 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 08:51:29 +1100 Subject: [PATCH 66/78] perf(dom): use set to store taken dedupe keys --- packages/dom/src/renderDOMHead.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/dom/src/renderDOMHead.ts b/packages/dom/src/renderDOMHead.ts index 17efdae9..79c255fb 100644 --- a/packages/dom/src/renderDOMHead.ts +++ b/packages/dom/src/renderDOMHead.ts @@ -40,6 +40,9 @@ export async function renderDOMHead>(head: T, options: Ren state = { elMap: { htmlAttrs: dom.documentElement, bodyAttrs: dom.body }, } as any as DomState + + const takenDedupeKeys = new Set() + for (const key of ['body', 'head']) { const children = dom[key as 'head' | 'body']?.children const tags: HeadTag[] = [] @@ -57,11 +60,15 @@ export async function renderDOMHead>(head: T, options: Ren innerHTML: c.innerHTML, } // we need to account for the fact that duplicate tags may exist as some are supported, increment the dedupe key + const dedupeKey = tagDedupeKey(t) + let d = dedupeKey let i = 1 - let d = tagDedupeKey(t) - while (d && tags.find(t => t._d === d)) - d = `${d}:${i++}` - t._d = d || undefined + while (d && takenDedupeKeys.has(d)) + d = `${dedupeKey}:${i++}` + if (d) { + t._d = d + takenDedupeKeys.add(d) + } tags.push(t) state.elMap[c.getAttribute('data-hid') || hashTag(t)] = c } From c2aa41bcf9123b387502f819ddd543cd114b142a Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 09:04:29 +1100 Subject: [PATCH 67/78] perf(ssr): use string concatenation in propsToString --- packages/ssr/src/util/propsToString.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/ssr/src/util/propsToString.ts b/packages/ssr/src/util/propsToString.ts index e4321bf0..8e114bfa 100644 --- a/packages/ssr/src/util/propsToString.ts +++ b/packages/ssr/src/util/propsToString.ts @@ -3,12 +3,19 @@ function encodeAttribute(value: string) { } export function propsToString(props: Record) { - const attrs: string[] = [] + let attrs = ' ' - for (const [key, value] of Object.entries(props)) { - if (value !== false && value !== null) - attrs.push(value === true ? key : `${key}="${encodeAttribute(value)}"`) + for (const key in props) { + if (!Object.prototype.hasOwnProperty.call(props, key)) { + continue + } + + const value = props[key] + + if (value !== false && value !== null) { + attrs += value === true ? `${key} ` : `${key}="${encodeAttribute(value)}" ` + } } - return `${attrs.length > 0 ? ' ' : ''}${attrs.join(' ')}` + return attrs.trimEnd() } From 3c99210be6648d064c0fe6fd9814dcab57a9515d Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 15:15:29 +1100 Subject: [PATCH 68/78] perf(ssr): add space before attrs in propsToString --- packages/ssr/src/util/propsToString.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ssr/src/util/propsToString.ts b/packages/ssr/src/util/propsToString.ts index 8e114bfa..e8d18292 100644 --- a/packages/ssr/src/util/propsToString.ts +++ b/packages/ssr/src/util/propsToString.ts @@ -3,7 +3,7 @@ function encodeAttribute(value: string) { } export function propsToString(props: Record) { - let attrs = ' ' + let attrs = '' for (const key in props) { if (!Object.prototype.hasOwnProperty.call(props, key)) { @@ -13,9 +13,9 @@ export function propsToString(props: Record) { const value = props[key] if (value !== false && value !== null) { - attrs += value === true ? `${key} ` : `${key}="${encodeAttribute(value)}" ` + attrs += value === true ? ` ${key}` : ` ${key}="${encodeAttribute(value)}"` } } - return attrs.trimEnd() + return attrs } From 9e4e2711440a9deb94c405f3986f80f5a4e1f53f Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:08:37 +1100 Subject: [PATCH 69/78] perf(ssr): use string concatenation in ssrRenderTags --- packages/ssr/src/util/ssrRenderTags.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ssr/src/util/ssrRenderTags.ts b/packages/ssr/src/util/ssrRenderTags.ts index e583804d..c10602fe 100644 --- a/packages/ssr/src/util/ssrRenderTags.ts +++ b/packages/ssr/src/util/ssrRenderTags.ts @@ -3,25 +3,29 @@ import { propsToString, tagToString } from '.' export function ssrRenderTags(tags: T[], options?: RenderSSRHeadOptions) { const schema: { - tags: Record<'head' | 'bodyClose' | 'bodyOpen', string[]> + tags: Record<'head' | 'bodyClose' | 'bodyOpen', string> htmlAttrs: HeadTag['props'] bodyAttrs: HeadTag['props'] - } = { htmlAttrs: {}, bodyAttrs: {}, tags: { head: [], bodyClose: [], bodyOpen: [] } } + } = { htmlAttrs: {}, bodyAttrs: {}, tags: { head: '', bodyClose: '', bodyOpen: '' } } + + const lineBreaks = !options?.omitLineBreaks ? '\n' : '' for (const tag of tags) { if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs') { schema[tag.tag] = { ...schema[tag.tag], ...tag.props } continue } - schema.tags[tag.tagPosition || 'head'].push(tagToString(tag)) + const s = tagToString(tag) + const tagPosition = tag.tagPosition || 'head' + schema.tags[tagPosition] += schema.tags[tagPosition] + ? `${lineBreaks}${s}` + : s } - const lineBreaks = !options?.omitLineBreaks ? '\n' : '' - return { - headTags: schema.tags.head.join(lineBreaks), - bodyTags: schema.tags.bodyClose.join(lineBreaks), - bodyTagsOpen: schema.tags.bodyOpen.join(lineBreaks), + headTags: schema.tags.head, + bodyTags: schema.tags.bodyClose, + bodyTagsOpen: schema.tags.bodyOpen, htmlAttrs: propsToString(schema.htmlAttrs), bodyAttrs: propsToString(schema.bodyAttrs), } From 0967e7aeefe7e6cd0dfe71b9d57f4e343ebeedd3 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:12:44 +1100 Subject: [PATCH 70/78] perf(ssr): use object.assign instead of spread operator --- packages/ssr/src/util/ssrRenderTags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ssr/src/util/ssrRenderTags.ts b/packages/ssr/src/util/ssrRenderTags.ts index c10602fe..7973d9c0 100644 --- a/packages/ssr/src/util/ssrRenderTags.ts +++ b/packages/ssr/src/util/ssrRenderTags.ts @@ -12,7 +12,7 @@ export function ssrRenderTags(tags: T[], options?: RenderSSRH for (const tag of tags) { if (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs') { - schema[tag.tag] = { ...schema[tag.tag], ...tag.props } + Object.assign(schema[tag.tag], tag.props) continue } const s = tagToString(tag) From c47686123977bf1cc23ebd551bae5f32c6574aee Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:21:33 +1100 Subject: [PATCH 71/78] perf(shared): use for of instead of array map --- packages/shared/src/normalise.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 008fc3a5..fab46811 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -158,8 +158,10 @@ export function normaliseEntryTags(e: HeadEntry): Th continue } if (Array.isArray(v)) { - // @ts-expect-error untyped - tagPromises.push(...v.map(props => normaliseTag(k as keyof Head, props, e))) + for (const props of v) { + // @ts-expect-error untyped + tagPromises.push(normaliseTag(k as keyof Head, props, e)) + } continue } // @ts-expect-error untyped From 1548f169015990a216903532459197d6d86197e0 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:27:37 +1100 Subject: [PATCH 72/78] perf(shared): check the static string first in fixKeyCase --- packages/shared/src/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index c3a54b28..b9b63e20 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -105,7 +105,7 @@ function fixKeyCase(key: string) { const updated = key.replace(/([A-Z])/g, '-$1').toLowerCase() const prefixIndex = updated.indexOf('-') const fKey = updated.substring(0, prefixIndex) - if (openGraphNamespaces.has(fKey) || fKey === 'twitter') + if (fKey === 'twitter' || openGraphNamespaces.has(fKey)) return key.replace(/([A-Z])/g, ':$1').toLowerCase() return updated } From 168c79aeeaa1c22539bdd2ac1f432467c7de53f9 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:39:43 +1100 Subject: [PATCH 73/78] perf(shared): use for in & for of loops in meta --- packages/shared/src/meta.ts | 46 ++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/meta.ts b/packages/shared/src/meta.ts index b9b63e20..551e3eb6 100644 --- a/packages/shared/src/meta.ts +++ b/packages/shared/src/meta.ts @@ -120,8 +120,12 @@ function changeKeyCasingDeep(input: T): T { return input const output: Record = {} - for (const [key, value] of Object.entries(input as object)) - output[fixKeyCase(key)] = changeKeyCasingDeep(value) + for (const key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + output[fixKeyCase(key)] = changeKeyCasingDeep(input[key]) + } return output as T } @@ -153,10 +157,14 @@ const ObjectArrayEntries = new Set(['og:image', 'og:video', 'og:audio', 'twitter function sanitize(input: Record) { const out: Record = {} - Object.entries(input).forEach(([k, v]) => { + for (const k in input) { + if (!Object.prototype.hasOwnProperty.call(input, k)) { + continue + } + const v = input[k] if (String(v) !== 'false' && k) out[k] = v - }) + } return out } @@ -167,11 +175,15 @@ function handleObjectEntry(key: string, v: Record) { const attr = resolveMetaKeyType(fKey) if (ObjectArrayEntries.has(fKey as keyof MetaFlatInput)) { const input: MetaFlatInput = {} - // we need to prefix the keys with og: - Object.entries(value).forEach(([k, v]) => { + for (const k in value) { + if (!Object.prototype.hasOwnProperty.call(value, k)) { + continue + } + + // we need to prefix the keys with og: // @ts-expect-error untyped - input[`${key}${k === 'url' ? '' : `${k.charAt(0).toUpperCase()}${k.slice(1)}`}`] = v - }) + input[`${key}${k === 'url' ? '' : `${k[0].toUpperCase()}${k.slice(1)}`}`] = value[k] + } return unpackMeta(input) // sort by property name // @ts-expect-error untyped @@ -188,24 +200,30 @@ export function unpackMeta(input: T): Required['m const extras: BaseMeta[] = [] // need to handle array input of the object const primitives: Record = {} - Object.entries(input).forEach(([key, value]) => { + for (const key in input) { + if (!Object.prototype.hasOwnProperty.call(input, key)) { + continue + } + + const value = input[key] + if (!Array.isArray(value)) { if (typeof value === 'object' && value) { if (ObjectArrayEntries.has(fixKeyCase(key) as keyof MetaFlatInput)) { extras.push(...handleObjectEntry(key, value)) - return + continue } primitives[key] = sanitize(value) } else { primitives[key] = value } - return + continue } - value.forEach((v) => { + for (const v of value) { extras.push(...(typeof v === 'string' ? unpackMeta({ [key]: v }) as BaseMeta[] : handleObjectEntry(key, v))) - }) - }) + } + } const meta = unpackToArray((primitives), { key({ key }) { From bd3038f3fee7655a46105a8d3a030395573c0866 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:42:55 +1100 Subject: [PATCH 74/78] perf(schema-org): use for in loop in resolveNodeId --- packages/schema-org/src/core/resolve.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/schema-org/src/core/resolve.ts b/packages/schema-org/src/core/resolve.ts index b2e6a7cc..8efa61fe 100644 --- a/packages/schema-org/src/core/resolve.ts +++ b/packages/schema-org/src/core/resolve.ts @@ -118,11 +118,18 @@ export function resolveNodeId(node: T, ctx: SchemaOrgGraph, res alias = type.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() } const hashNodeData: Record = {} - Object.entries(node).forEach(([key, val]) => { - // remove runtime private fields - if (key[0] !== '_') - hashNodeData[key] = val - }) + for (const key in node) { + if (key[0] === '_') { + continue + } + + if (!Object.prototype.hasOwnProperty.call(node, key)) { + continue + } + + hashNodeData[key] = node[key] + } + node['@id'] = prefixId(ctx.meta[prefix], `#/schema/${alias}/${node['@id'] || hashCode(JSON.stringify(hashNodeData))}`) return node } From c6e7c35f1b490defaa3e4aa3fe1691905c345679 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 16:50:28 +1100 Subject: [PATCH 75/78] perf(schema-org): use for in loop in stripEmptyProperties --- packages/schema-org/src/utils/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/schema-org/src/utils/index.ts b/packages/schema-org/src/utils/index.ts index 195c1d72..90dd26d2 100644 --- a/packages/schema-org/src/utils/index.ts +++ b/packages/schema-org/src/utils/index.ts @@ -129,7 +129,11 @@ export function resolveAsGraphKey(key?: Id | string) { * Removes attributes which have a null or undefined value */ export function stripEmptyProperties(obj: any) { - Object.keys(obj).forEach((k) => { + for (const k in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, k)) { + continue + } + if (obj[k] && typeof obj[k] === 'object') { // avoid walking vue reactivity if (obj[k].__v_isReadonly || obj[k].__v_isRef) @@ -139,6 +143,7 @@ export function stripEmptyProperties(obj: any) { } if (obj[k] === '' || obj[k] === null || obj[k] === undefined) delete obj[k] - }) + } + return obj } From 36b1dc9a24a84ac776d5333f9f6e657dc6b0914f Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 17:12:03 +1100 Subject: [PATCH 76/78] perf(unhead): delete tag._duped if exists in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 684fc05e..b3ca3663 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -88,12 +88,13 @@ export default defineHeadPlugin({ const tag = deduping[key] // @ts-expect-error runtime type const dupes = tag._duped - // @ts-expect-error runtime type - delete tag._duped newTags.push(tag) // add the duped tags to the new tags - if (dupes) + if (dupes) { + // @ts-expect-error runtime type + delete tag._duped newTags.push(...dupes) + } } ctx.tags = newTags // now filter out invalid meta From a6da0f908100e405bb76b110541c471de77e9eb1 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 17:25:04 +1100 Subject: [PATCH 77/78] perf(unhead): remove loop for check third party dedupe keys in dedupe plugin --- packages/unhead/src/plugins/dedupe.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index b3ca3663..c1ce4d1e 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -3,17 +3,21 @@ import { HasElementTags, defineHeadPlugin, tagDedupeKey, tagWeight } from '@unhe const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) -const thirdPartyDedupeKeys = ['hid', 'vmid', 'key'] - export default defineHeadPlugin({ hooks: { 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys - for (const key of thirdPartyDedupeKeys) { - if (tag.props[key]) { - tag.key = tag.props[key] - delete tag.props[key] - } + if (tag.props.hid) { + tag.key = tag.props.hid + delete tag.props.hid + } + if (tag.props.vmid) { + tag.key = tag.props.vmid + delete tag.props.vmid + } + if (tag.props.key) { + tag.key = tag.props.key + delete tag.props.key } const generatedKey = tagDedupeKey(tag) const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false) From 4f530d810fc968baec8b2801361c7fccb3db12c4 Mon Sep 17 00:00:00 2001 From: Negezor Date: Thu, 18 Jul 2024 17:56:23 +1100 Subject: [PATCH 78/78] perf(shared): reduce operations in eventHandler plugin --- packages/unhead/src/plugins/eventHandlers.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/plugins/eventHandlers.ts index 24a881d3..5b8e47f4 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/plugins/eventHandlers.ts @@ -19,7 +19,7 @@ export default defineHeadPlugin(head => ({ for (const key in props) { // on - if (!(key[0] === 'o' && key[1] === 'n')) { + if (key[0] !== 'o' || key[1] !== 'n') { continue } @@ -51,8 +51,14 @@ export default defineHeadPlugin(head => ({ } }, 'dom:renderTag': ({ $el, tag }) => { + const dataset = ($el as HTMLScriptElement | undefined)?.dataset + + if (!dataset) { + return + } + // this is only handling SSR rendered tags with event handlers - for (const k in (($el as HTMLScriptElement | undefined)?.dataset || {})) { + for (const k in dataset) { if (!k.endsWith('fired')) { continue }