diff --git a/packages/addons/package.json b/packages/addons/package.json index bfe2c498..d428b956 100644 --- a/packages/addons/package.json +++ b/packages/addons/package.json @@ -53,6 +53,9 @@ "stub": "unbuild . --stub", "export:sizes": "npx export-size . -r" }, + "peerDependencies": { + "unhead": "workspace:*" + }, "dependencies": { "@rollup/pluginutils": "^5.1.4", "@unhead/schema": "workspace:*", @@ -64,9 +67,6 @@ "unplugin": "^2.1.2", "unplugin-ast": "^0.13.1" }, - "peerDependencies": { - "unhead": "workspace:*" - }, "devDependencies": { "@babel/types": "^7.26.3" } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 7657c8f9..13f7f91e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,5 @@ export const SelfClosingTags = new Set(['meta', 'link', 'base']) +export const DupeableTags = new Set(['link', 'style', 'script', 'noscript']) export const TagsWithInnerContent = new Set(['title', 'titleTemplate', 'script', 'style', 'noscript']) export const HasElementTags = new Set([ 'base', @@ -24,7 +25,7 @@ export const ValidHeadTags = new Set([ export const UniqueTags = new Set(['base', 'title', 'titleTemplate', 'bodyAttrs', 'htmlAttrs', 'templateParams']) -export const TagConfigKeys = new Set(['tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'innerHTML', 'textContent', 'processTemplateParams']) +export const TagConfigKeys = new Set(['key', 'tagPosition', 'tagPriority', 'tagDuplicateStrategy', 'innerHTML', 'textContent', 'processTemplateParams']) export const IsBrowser = typeof window !== 'undefined' diff --git a/packages/shared/src/normalise.ts b/packages/shared/src/normalise.ts index 174962d6..131ef2a2 100644 --- a/packages/shared/src/normalise.ts +++ b/packages/shared/src/normalise.ts @@ -1,5 +1,7 @@ import type { Head, HeadEntry, HeadTag } from '@unhead/schema' -import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from './constants' +import { DupeableTags, TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from './constants' +import { hashCode } from './hashCode' +import { tagDedupeKey } from './tagDedupeKey' export function normaliseTag(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry, normalizedProps?: HeadTag['props']): T | T[] { const props = normalizedProps || normaliseProps( @@ -27,6 +29,19 @@ export function normaliseTag(tagName: T['tag'], input: HeadTa delete tag.props[k] } } + // only if the user has provided a key + // only tags which can't dedupe themselves, ssr only + if (tag.key && DupeableTags.has(tag.tag)) { + // add a HTML key so the client-side can hydrate without causing duplicates + tag.props['data-hid'] = tag._h = hashCode(tag.key!) + } + const generatedKey = tagDedupeKey(tag) + if (generatedKey && !generatedKey.startsWith('meta:og:') && !generatedKey.startsWith('meta:twitter:')) { + delete tag.key + } + const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false) + if (dedupe) + tag._d = dedupe // shorthand for objects if (tag.tag === 'script') { if (typeof tag.innerHTML === 'object') { diff --git a/packages/unhead/src/client/createHead.ts b/packages/unhead/src/client/createHead.ts index b0666e6d..5f0a863a 100644 --- a/packages/unhead/src/client/createHead.ts +++ b/packages/unhead/src/client/createHead.ts @@ -2,17 +2,19 @@ import type { CreateHeadOptions, Head } from '@unhead/schema' import { IsBrowser } from '@unhead/shared' import { unheadCtx } from '../context' import { createHeadCore } from '../createHead' -import { DomPlugin } from './domPlugin' +import { DomPlugin } from './plugins/domPlugin' +import { ClientEventHandlerPlugin } from './plugins/eventHandlers' export function createHead = Head>(options: CreateHeadOptions = {}) { const head = createHeadCore({ document: (IsBrowser ? document : undefined), ...options, + plugins: [ + ...(options.plugins || []), + DomPlugin(), + ClientEventHandlerPlugin, + ], }) - head.use(DomPlugin()) - // should only be one instance client-side - if (!head.ssr && IsBrowser) { - unheadCtx.set(head, true) - } + unheadCtx.set(head, true) return head } diff --git a/packages/unhead/src/client/index.ts b/packages/unhead/src/client/index.ts index 05497f7a..f0d3c880 100644 --- a/packages/unhead/src/client/index.ts +++ b/packages/unhead/src/client/index.ts @@ -1,4 +1,4 @@ export * from './createHead' export * from './debounced' -export * from './domPlugin' +export * from './plugins/domPlugin' export * from './renderDOMHead' diff --git a/packages/unhead/src/client/domPlugin.ts b/packages/unhead/src/client/plugins/domPlugin.ts similarity index 86% rename from packages/unhead/src/client/domPlugin.ts rename to packages/unhead/src/client/plugins/domPlugin.ts index ac6f7a16..7dd0dc2f 100644 --- a/packages/unhead/src/client/domPlugin.ts +++ b/packages/unhead/src/client/plugins/domPlugin.ts @@ -1,6 +1,6 @@ -import type { RenderDomHeadOptions } from './renderDOMHead' +import type { RenderDomHeadOptions } from '../renderDOMHead' import { defineHeadPlugin } from '@unhead/shared' -import { debouncedRenderDOMHead } from './debounced' +import { debouncedRenderDOMHead } from '../debounced' export interface DomPluginOptions extends RenderDomHeadOptions { delayFn?: (fn: () => void) => void diff --git a/packages/unhead/src/plugins/eventHandlers.ts b/packages/unhead/src/client/plugins/eventHandlers.ts similarity index 72% rename from packages/unhead/src/plugins/eventHandlers.ts rename to packages/unhead/src/client/plugins/eventHandlers.ts index 146ee2bc..b52ebc43 100644 --- a/packages/unhead/src/plugins/eventHandlers.ts +++ b/packages/unhead/src/client/plugins/eventHandlers.ts @@ -1,4 +1,4 @@ -import { defineHeadPlugin, hashCode, NetworkEvents } from '@unhead/shared' +import { defineHeadPlugin, NetworkEvents } from '@unhead/shared' const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) @@ -7,7 +7,7 @@ const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) * * When SSR we need to strip out these values. On CSR we */ -export default defineHeadPlugin(head => ({ +export const ClientEventHandlerPlugin = defineHeadPlugin({ hooks: { 'tags:resolve': (ctx) => { for (const tag of ctx.tags) { @@ -33,21 +33,10 @@ export default defineHeadPlugin(head => ({ continue } - // insert a inline script to set the status of onload and onerror - if (head.ssr && NetworkEvents.has(key)) { - props[key] = `this.dataset.${key}fired = true` - } - else { - delete props[key] - } - + delete props[key] tag._eventHandlers = tag._eventHandlers || {} tag._eventHandlers![key] = value } - - if (head.ssr && tag._eventHandlers && (tag.props.src || tag.props.href)) { - tag.key = tag.key || hashCode(tag.props.src || tag.props.href) - } } }, 'dom:renderTag': ({ $el, tag }) => { @@ -75,4 +64,4 @@ export default defineHeadPlugin(head => ({ } }, }, -})) +}) diff --git a/packages/unhead/src/createHead.ts b/packages/unhead/src/createHead.ts index 5b8ad6f0..078c2f85 100644 --- a/packages/unhead/src/createHead.ts +++ b/packages/unhead/src/createHead.ts @@ -12,8 +12,6 @@ import type { import { normaliseEntryTags } from '@unhead/shared' import { createHooks } from 'hookable' import DedupePlugin from './plugins/dedupe' -import EventHandlersPlugin from './plugins/eventHandlers' -import HashKeyedPlugin from './plugins/hashKeyed' import SortPlugin from './plugins/sort' import TemplateParamsPlugin from './plugins/templateParams' import TitleTemplatePlugin from './plugins/titleTemplate' @@ -114,8 +112,6 @@ export function createHeadCore = Head>(options: Cr } ;[ DedupePlugin, - EventHandlersPlugin, - HashKeyedPlugin, SortPlugin, TemplateParamsPlugin, TitleTemplatePlugin, diff --git a/packages/unhead/src/legacy.ts b/packages/unhead/src/legacy.ts index 83cff701..0cb1641c 100644 --- a/packages/unhead/src/legacy.ts +++ b/packages/unhead/src/legacy.ts @@ -1,24 +1,39 @@ import type { CreateHeadOptions, Head } from '@unhead/schema' import { IsBrowser } from '@unhead/shared' -import { DomPlugin } from './client/domPlugin' +import { DomPlugin } from './client/plugins/domPlugin' +import { ClientEventHandlerPlugin } from './client/plugins/eventHandlers' import { unheadCtx } from './context' import { createHeadCore } from './createHead' import { DeprecationsPlugin } from './optionalPlugins/deprecations' import { PromisesPlugin } from './optionalPlugins/promises' +import { ServerEventHandlerPlugin } from './server/plugins/eventHandlers' +import { PayloadPlugin } from './server/plugins/payload' export function createServerHead = Head>(options: CreateHeadOptions = {}) { - // @ts-expect-error untyped - const head = createHeadCore({ disableCapoSorting: true, ...options, document: false }) - head.use(DeprecationsPlugin) - head.use(PromisesPlugin) - return head + return createHeadCore({ + disableCapoSorting: true, + ...options, + // @ts-expect-error untyped + document: false, + plugins: [ + ...(options.plugins || []), + DomPlugin(), + DeprecationsPlugin, + PromisesPlugin, + ServerEventHandlerPlugin, + PayloadPlugin, + ], + }) } export function createHead = Head>(options: CreateHeadOptions = {}) { - const head = createHeadCore({ disableCapoSorting: true, ...options }) - head.use(DomPlugin()) - head.use(DeprecationsPlugin) - head.use(PromisesPlugin) + const head = createHeadCore({ disableCapoSorting: true, ...options, plugins: [ + ...(options.plugins || []), + DomPlugin(), + DeprecationsPlugin, + PromisesPlugin, + ClientEventHandlerPlugin, + ] }) // should only be one instance client-side if (!head.ssr && IsBrowser) { unheadCtx.set(head, true) diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 71f5df54..431d386c 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -1,23 +1,10 @@ import type { HeadTag } from '@unhead/schema' -import { defineHeadPlugin, HasElementTags, hashTag, tagDedupeKey, tagWeight } from '@unhead/shared' +import { defineHeadPlugin, HasElementTags, hashTag, tagWeight } from '@unhead/shared' const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) export default defineHeadPlugin(head => ({ hooks: { - 'tag:normalise': ({ tag }) => { - if (tag.props.key) { - tag.key = tag.props.key - delete tag.props.key - } - const generatedKey = tagDedupeKey(tag) - if (generatedKey && !generatedKey.startsWith('meta:og:') && !generatedKey.startsWith('meta:twitter:')) { - delete tag.key - } - const dedupe = generatedKey || (tag.key ? `${tag.tag}:${tag.key}` : false) - if (dedupe) - tag._d = dedupe - }, 'tags:resolve': (ctx) => { // 1. Dedupe tags const deduping: Record = Object.create(null) diff --git a/packages/unhead/src/plugins/hashKeyed.ts b/packages/unhead/src/plugins/hashKeyed.ts deleted file mode 100644 index 69dbb3c1..00000000 --- a/packages/unhead/src/plugins/hashKeyed.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineHeadPlugin, hashCode } from '@unhead/shared' - -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.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!) - } - }, - }, -}) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index e8601e8c..d441f38a 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -33,11 +33,8 @@ export default defineHeadPlugin(head => ({ const bWeight = tagWeight(head, b) // 2c. sort based on critical tags - if (aWeight < bWeight) { - return -1 - } - else if (aWeight > bWeight) { - return 1 + if (aWeight !== bWeight) { + return aWeight - bWeight } // 2b. sort tags in their natural order diff --git a/packages/unhead/src/server/createHead.ts b/packages/unhead/src/server/createHead.ts index f58709d1..6735c6ff 100644 --- a/packages/unhead/src/server/createHead.ts +++ b/packages/unhead/src/server/createHead.ts @@ -1,10 +1,17 @@ import type { CreateHeadOptions, Head } from '@unhead/schema' import { createHeadCore } from '../createHead' -import PayloadPlugin from './plugins/payload' +import { ServerEventHandlerPlugin } from './plugins/eventHandlers' +import { PayloadPlugin } from './plugins/payload' export function createHead = Head>(options: CreateHeadOptions = {}) { - // @ts-expect-error untyped - const head = createHeadCore({ ...options, document: false }) - head.use(PayloadPlugin) - return head + return createHeadCore({ + ...options, + // @ts-expect-error untyped + document: false, + plugins: [ + ...(options.plugins || []), + PayloadPlugin, + ServerEventHandlerPlugin, + ], + }) } diff --git a/packages/unhead/src/server/plugins/eventHandlers.ts b/packages/unhead/src/server/plugins/eventHandlers.ts new file mode 100644 index 00000000..44de637c --- /dev/null +++ b/packages/unhead/src/server/plugins/eventHandlers.ts @@ -0,0 +1,50 @@ +import { defineHeadPlugin, hashCode, NetworkEvents } from '@unhead/shared' + +const ValidEventTags = new Set(['script', 'link', 'bodyAttrs']) + +/** + * Supports DOM event handlers (i.e `onload`) as functions. + * + * When SSR we need to strip out these values. On CSR we + */ +export const ServerEventHandlerPlugin = defineHeadPlugin({ + hooks: { + 'tags:resolve': (ctx) => { + for (const tag of ctx.tags) { + if (!ValidEventTags.has(tag.tag)) { + continue + } + + const props = tag.props + + let hasEventHandlers = false + for (const key in props) { + // on + if (key[0] !== 'o' || key[1] !== 'n') { + continue + } + + if (!Object.prototype.hasOwnProperty.call(props, key)) { + continue + } + + const value = props[key] + + if (typeof value !== 'function') { + continue + } + + // insert a inline script to set the status of onload and onerror + if (NetworkEvents.has(key)) { + props[key] = `this.dataset.${key}fired = true` + hasEventHandlers = true + } + } + + if (hasEventHandlers && (tag.props.src || tag.props.href)) { + tag.key = tag.key || hashCode(tag.props.src || tag.props.href) + } + } + }, + }, +}) diff --git a/packages/unhead/src/server/plugins/payload.ts b/packages/unhead/src/server/plugins/payload.ts index ecc28609..b3475b27 100644 --- a/packages/unhead/src/server/plugins/payload.ts +++ b/packages/unhead/src/server/plugins/payload.ts @@ -1,7 +1,6 @@ import { defineHeadPlugin } from '@unhead/shared' -export default defineHeadPlugin({ - mode: 'server', +export const PayloadPlugin = defineHeadPlugin({ hooks: { 'tags:beforeResolve': (ctx) => { const payload: { titleTemplate?: string | ((s: string) => string), templateParams?: Record, title?: string } = {} diff --git a/test/unhead/ssr/eventHandlers.test.ts b/test/unhead/ssr/eventHandlers.test.ts index b5528afc..2191b675 100644 --- a/test/unhead/ssr/eventHandlers.test.ts +++ b/test/unhead/ssr/eventHandlers.test.ts @@ -1,11 +1,11 @@ import { renderSSRHead } from '@unhead/ssr' import { useHead } from 'unhead' import { describe, it } from 'vitest' -import { createHeadWithContext } from '../../util' +import { createServerHeadWithContext } from '../../util' describe('ssr event handlers', () => { it('basic', async () => { - const head = createHeadWithContext() + const head = createServerHeadWithContext() useHead({ script: [ diff --git a/test/unhead/ssr/useScript.test.ts b/test/unhead/ssr/useScript.test.ts index b4e4e73e..643234b7 100644 --- a/test/unhead/ssr/useScript.test.ts +++ b/test/unhead/ssr/useScript.test.ts @@ -1,7 +1,7 @@ import { renderSSRHead } from '@unhead/ssr' import { useScript } from 'unhead' import { describe, it } from 'vitest' -import { createHeadWithContext } from '../../util' +import { createHeadWithContext, createServerHeadWithContext } from '../../util' describe('ssr useScript', () => { it('default', async () => { @@ -23,7 +23,7 @@ describe('ssr useScript', () => { `) }) it('server', async () => { - const head = createHeadWithContext() + const head = createServerHeadWithContext() useScript({ src: 'https://cdn.example.com/script.js', @@ -43,7 +43,7 @@ describe('ssr useScript', () => { `) }) it('await ', async () => { - const head = createHeadWithContext() + const head = createServerHeadWithContext() // mock a promise, test that it isn't resolved in 1 second useScript<{ foo: 'bar' }>({ @@ -64,7 +64,7 @@ describe('ssr useScript', () => { `) }) it('google ', async () => { - const head = createHeadWithContext() + const head = createServerHeadWithContext() const gtag = useScript<{ dataLayer: any[] }>({ src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B',