Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!: only handle promise input when opted in #445

Merged
merged 3 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix!: drop promise input support
  • Loading branch information
harlan-zw committed Jan 7, 2025
commit 304824767c8afc6fca823f876232ca2517559df5
3 changes: 1 addition & 2 deletions docs/content/_code-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ useHead({
useServerHead({
link: [
{
// promises supported
href: import('~/assets/MyFont.css?url'),
href: '/assets/MyFont.css',
rel: 'stylesheet',
type: 'text/css'
}
Expand Down
3 changes: 0 additions & 3 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
export * from './array'
export * from './constants'
export * from './defineHeadPlugin'
export * from './hashCode'
export * from './meta'
export * from './normalise'
export * from './safe'
export * from './script'
export * from './sort'
export * from './tagDedupeKey'
export * from './templateParams'
export * from './thenable'
export * from './titleTemplate'
90 changes: 15 additions & 75 deletions packages/shared/src/normalise.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import type { Head, HeadEntry, HeadTag } from '@unhead/schema'
import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from '.'
import { type Thenable, thenable } from './thenable'
import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags } from './constants'

export function normaliseTag<T extends HeadTag>(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry<T>, normalizedProps?: HeadTag['props']): Thenable<T | T[]> {
export function normaliseTag<T extends HeadTag>(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry<T>, normalizedProps?: HeadTag['props']): T | T[] {
const props = normalizedProps || normaliseProps<T>(
// explicitly check for an object
// @ts-expect-error untyped
typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise)
typeof input === 'object' && typeof input !== 'function'
? { ...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,
Expand Down Expand Up @@ -68,14 +61,8 @@ export function normaliseStyleClassProps<T extends 'class' | 'style'>(key: T, v:
.join(sep)
}

function nestedNormaliseProps<T extends HeadTag>(
props: T['props'],
virtual: boolean,
keys: (keyof T['props'])[],
startIndex: number,
): Thenable<void> {
for (let i = startIndex; i < keys.length; i += 1) {
const k = keys[i]
export function normaliseProps<T extends HeadTag>(props: T['props'], virtual: boolean = false) {
for (const k in props) {
// handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes
// class has special handling
if (k === 'class' || k === 'style') {
Expand All @@ -84,15 +71,6 @@ function nestedNormaliseProps<T extends HeadTag>(
continue
}

// @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

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
Expand All @@ -110,44 +88,14 @@ function nestedNormaliseProps<T extends HeadTag>(
}
}
}
}

export function normaliseProps<T extends HeadTag>(props: T['props'], virtual: boolean = false): Thenable<T['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)
export const TagEntityBits = 10

function nestedNormaliseEntryTags(headTags: HeadTag[], tagPromises: Thenable<HeadTag | HeadTag[]>[], startIndex: number): Thenable<unknown> {
for (let i = startIndex; i < tagPromises.length; i += 1) {
const tags = tagPromises[i]

if (tags instanceof Promise) {
return tags.then((val) => {
tagPromises[i] = val

return nestedNormaliseEntryTags(headTags, tagPromises, i)
})
}

if (Array.isArray(tags)) {
headTags.push(...tags)
}
else {
headTags.push(tags)
}
}
}

export function normaliseEntryTags<T extends object = Head>(e: HeadEntry<T>): Thenable<HeadTag[]> {
const tagPromises: Thenable<HeadTag | HeadTag[]>[] = []
export function normaliseEntryTags<T extends object = Head>(e: HeadEntry<T>): HeadTag[] {
const tags: (HeadTag | HeadTag[])[] = []
const input = e.resolvedInput as T
for (const k in input) {
if (!Object.prototype.hasOwnProperty.call(input, k)) {
Expand All @@ -160,26 +108,18 @@ export function normaliseEntryTags<T extends object = Head>(e: HeadEntry<T>): Th
if (Array.isArray(v)) {
for (const props of v) {
// @ts-expect-error untyped
tagPromises.push(normaliseTag(k as keyof Head, props, e))
tags.push(normaliseTag(k as keyof Head, props, e))
}
continue
}
// @ts-expect-error untyped
tagPromises.push(normaliseTag(k as keyof Head, v, e))
tags.push(normaliseTag(k as keyof Head, v, e))
}

if (tagPromises.length === 0) {
return []
}

const headTags: HeadTag[] = []

return thenable(nestedNormaliseEntryTags(headTags, tagPromises, 0), () => (
headTags.map((t, i) => {
t._e = e._i
e.mode && (t._m = e.mode)
t._p = (e._i << TagEntityBits) + i
return t
})
))
return tags.flat().map((t, i) => {
t._e = e._i
e.mode && (t._m = e.mode)
t._p = (e._i << TagEntityBits) + i
return t
})
}
9 changes: 0 additions & 9 deletions packages/shared/src/thenable.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/unhead/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export default defineBuildConfig({
},
entries: [
{ input: 'src/index', name: 'index' },
{ input: 'src/optionalPlugins/index', name: 'optionalPlugins' },
],
})
1 change: 1 addition & 0 deletions packages/unhead/src/optionalPlugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './promises'
37 changes: 37 additions & 0 deletions packages/unhead/src/optionalPlugins/promises.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineHeadPlugin } from '@unhead/shared'

async function resolvePromisesRecursively(root: any): Promise<any> {
if (root instanceof Promise) {
return await root
}
// could be a root primitive, array or object
if (Array.isArray(root)) {
return Promise.all(root.map(r => resolvePromisesRecursively(r)))
}
if (typeof root === 'object') {
const resolved: Record<string, string> = {}

for (const k in root) {
if (!Object.prototype.hasOwnProperty.call(root, k)) {
continue
}

resolved[k] = await resolvePromisesRecursively(root[k])
}

return resolved
}
return root
}

export const PromisesPlugin = defineHeadPlugin(head => ({
hooks: {
'entries:resolve': async (ctx) => {
for (const k in ctx.entries) {
const resolved = await resolvePromisesRecursively(ctx.entries[k].input)
ctx.entries[k].input = resolved
console.log('resolved', ctx.entries[k].input, resolved)
}
},
},
}))
3 changes: 3 additions & 0 deletions packages/vue/test/promises.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useHead } from '@unhead/vue'
import { describe, it } from 'vitest'
import { PromisesPlugin } from '../../unhead/src/optionalPlugins/promises'
import { ssrVueAppWithUnhead } from './util'

describe('vue promises', () => {
Expand All @@ -14,6 +15,8 @@ describe('vue promises', () => {
},
],
})
}, {
plugins: [PromisesPlugin],
})

expect(await head.resolveTags()).toMatchInlineSnapshot(`
Expand Down
5 changes: 3 additions & 2 deletions packages/vue/test/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @vitest-environment jsdom

import type { CreateHeadOptions } from '@unhead/schema'
import type { JSDOM } from 'jsdom'
import type { App, Component } from 'vue'
import { renderSSRHead } from '@unhead/ssr'
Expand Down Expand Up @@ -28,8 +29,8 @@ export function csrVueAppWithUnhead(dom: JSDOM, fn: () => void | Promise<void>)
return head
}

export async function ssrVueAppWithUnhead(fn: () => void | Promise<void>) {
const head = createServerHead()
export async function ssrVueAppWithUnhead(fn: () => void | Promise<void>, options?: CreateHeadOptions) {
const head = createServerHead(options)
const app = createSSRApp({
async setup() {
fn()
Expand Down
5 changes: 4 additions & 1 deletion test/unhead/promises.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, it } from 'vitest'
import { PromisesPlugin } from '../../packages/unhead/src/optionalPlugins/promises'
import { createHeadWithContext } from '../util'

describe('promises', () => {
it('basic', async () => {
const head = createHeadWithContext()
const head = createHeadWithContext({
plugins: [PromisesPlugin],
})
head.push({
title: new Promise(resolve => resolve('hello')),
script: [
Expand Down
Loading