From c234e21f157d2b68e4777b56b620413c3c9550b9 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 9 Jan 2025 18:37:21 +1100 Subject: [PATCH] feat(scripts)!: `useScript` overhaul, `@unhead/scripts` (#436) * chore: release v1.11.14 * progress commit * chore: progress commit * chore: broken tests * chore: fix build * chore: fix build * doc: install fix * fix: no longer augment as promise, export legacy * fix: use forwarding proxy once loaded * chore: read me * feat: support event deduping --- .../1.usage/2.composables/4.use-script.md | 71 +++----- packages/schema/src/hooks.ts | 4 - packages/schema/src/index.ts | 3 - packages/scripts/README.md | 7 + packages/scripts/build.config.ts | 32 ++++ packages/scripts/legacy.d.ts | 1 + packages/scripts/overrides.d.ts | 9 + packages/scripts/package.json | 97 +++++++++++ packages/scripts/src/index.ts | 3 + packages/scripts/src/legacy.ts | 95 +++++++++++ packages/scripts/src/proxy.ts | 67 ++++++++ .../src/script.ts => scripts/src/types.ts} | 54 ++++-- .../composables => scripts/src}/useScript.ts | 151 ++++++++--------- packages/scripts/src/utils.ts | 31 ++++ packages/scripts/src/vue-legacy.ts | 109 ++++++++++++ packages/scripts/src/vue/index.ts | 1 + .../src/vue}/useScript.ts | 37 ++-- .../scripts/test/unit/dom.test.ts | 54 ++---- .../scripts/test/unit/e2e.test.ts | 9 +- packages/scripts/test/unit/events.test.ts | 50 ++++++ packages/scripts/test/unit/proxy.test.ts | 158 ++++++++++++++++++ .../scripts/test/unit/ssr.test.ts | 9 +- packages/scripts/test/unit/use.test.ts | 22 +++ .../test/unit/vue.test.ts} | 4 +- packages/scripts/test/unit/warmup.test.ts | 48 ++++++ packages/scripts/vue-legacy.d.ts | 1 + packages/scripts/vue.d.ts | 1 + packages/unhead/src/index.ts | 1 - packages/vue/src/index.ts | 1 - pnpm-lock.yaml | 51 ++++++ 30 files changed, 951 insertions(+), 230 deletions(-) create mode 100644 packages/scripts/README.md create mode 100644 packages/scripts/build.config.ts create mode 100644 packages/scripts/legacy.d.ts create mode 100644 packages/scripts/overrides.d.ts create mode 100644 packages/scripts/package.json create mode 100644 packages/scripts/src/index.ts create mode 100644 packages/scripts/src/legacy.ts create mode 100644 packages/scripts/src/proxy.ts rename packages/{schema/src/script.ts => scripts/src/types.ts} (65%) rename packages/{unhead/src/composables => scripts/src}/useScript.ts (66%) create mode 100644 packages/scripts/src/utils.ts create mode 100644 packages/scripts/src/vue-legacy.ts create mode 100644 packages/scripts/src/vue/index.ts rename packages/{vue/src/composables => scripts/src/vue}/useScript.ts (78%) rename test/unhead/dom/useScript.test.ts => packages/scripts/test/unit/dom.test.ts (56%) rename test/unhead/e2e/scripts.test.ts => packages/scripts/test/unit/e2e.test.ts (92%) create mode 100644 packages/scripts/test/unit/events.test.ts create mode 100644 packages/scripts/test/unit/proxy.test.ts rename test/unhead/ssr/useScript.test.ts => packages/scripts/test/unit/ssr.test.ts (91%) create mode 100644 packages/scripts/test/unit/use.test.ts rename packages/{vue/test/e2e/scripts.test.ts => scripts/test/unit/vue.test.ts} (96%) create mode 100644 packages/scripts/test/unit/warmup.test.ts create mode 100644 packages/scripts/vue-legacy.d.ts create mode 100644 packages/scripts/vue.d.ts diff --git a/docs/content/1.usage/2.composables/4.use-script.md b/docs/content/1.usage/2.composables/4.use-script.md index ee433a10..0ef23a5e 100644 --- a/docs/content/1.usage/2.composables/4.use-script.md +++ b/docs/content/1.usage/2.composables/4.use-script.md @@ -3,8 +3,6 @@ title: useScript description: Load third-party scripts with SSR support and a proxied API. --- -**Stable as of v1.9** - ## Features - 🪨 Turn a third-party script into a fully typed API @@ -14,6 +12,26 @@ description: Load third-party scripts with SSR support and a proxied API. - 🪝 Proxy API: Use a scripts functions before it's loaded (or while SSR) - 🇹 Fully typed APIs +## Installation + +As of Unhead v2, you will need to add the `@unhead/scripts` dependency to use `useScript`. + +::code-group + +```bash [yarn] +yarn add -D @unhead/scripts +``` + +```bash [npm] +npm install -D @unhead/scripts +``` + +```bash [pnpm] +pnpm add -D @unhead/scripts +``` + +:: + ## Background Loading scripts using the `useHead` composable is easy. @@ -314,28 +332,6 @@ const val = myScript.proxy.siteId // ❌ val will be a function const user = myScript.proxy.loadUser() // ❌ the result of calling any function is always void ```` -#### Stubbing - -In cases where you're using the Proxy API, you can additionally hook into the resolving of the proxy using the `stub` -option. - -For example, in a server context, we probably want to polyfill some returns so our scrits remains functional. - -```ts -const analytics = useScript<{ event: ((arg: string) => boolean) }>('/analytics.js', { - use() { return window.analytics }, - stub() { - if (import.meta.server) { - return { - event: (e) => { - console.log('event', e) - } - } - } - } -}) -``` - ## API ```ts @@ -420,33 +416,6 @@ fathom.then((api) => { }) ``` -#### `stub` - -A more advanced function used to stub out the logic of the API. This will be called on the server and client. - -This is particularly useful when the API you want to use is a primitive and you need to access it on the server. For instance, -pushing to `dataLayer` when using Google Tag Manager. - -```ts -const myScript = useScript({ - src: 'https://example.com/script.js', -}, { - use: () => window.myScript, - stub: ({ fn }) => { - // stub out behavior on server - if (process.server && fn === 'sendEvent') - return (opt: string) => fetch('https://api.example.com/event', { method: 'POST', body: opt }) - } -}) -const { sendEvent, doSomething } = myScript.proxy -// on server, will send a fetch to https://api.example.com/event -// on client it falls back to the real API -sendEvent('event') -// on server, will noop -// on client it falls back to the real API -doSomething() -``` - ## Script Instance API The `useScript` composable returns the script instance that you can use to interact with the script. diff --git a/packages/schema/src/hooks.ts b/packages/schema/src/hooks.ts index 9ccd6bc1..cb38f232 100644 --- a/packages/schema/src/hooks.ts +++ b/packages/schema/src/hooks.ts @@ -1,4 +1,3 @@ -import type { ScriptInstance } from './' import type { CreateHeadOptions, HeadEntry, Unhead } from './head' import type { HeadTag } from './tags' @@ -53,7 +52,4 @@ export interface HeadHooks { 'ssr:beforeRender': (ctx: ShouldRenderContext) => HookResult 'ssr:render': (ctx: { tags: HeadTag[] }) => HookResult 'ssr:rendered': (ctx: SSRRenderContext) => HookResult - - 'script:updated': (ctx: { script: ScriptInstance }) => HookResult - 'script:instance-fn': (ctx: { script: ScriptInstance, fn: string | symbol, exists: boolean }) => HookResult } diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 2b713ffd..1a9b69b4 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -2,7 +2,4 @@ export * from './head' export * from './hooks' export * from './safeSchema' export * from './schema' -export * from './script' export * from './tags' - -export {} diff --git a/packages/scripts/README.md b/packages/scripts/README.md new file mode 100644 index 00000000..807094d1 --- /dev/null +++ b/packages/scripts/README.md @@ -0,0 +1,7 @@ +# @unhead/scripts + +Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security. + +## License + +MIT License © 2022-PRESENT [Harlan Wilton](https://github.com/harlan-zw) diff --git a/packages/scripts/build.config.ts b/packages/scripts/build.config.ts new file mode 100644 index 00000000..ae6d6d86 --- /dev/null +++ b/packages/scripts/build.config.ts @@ -0,0 +1,32 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + clean: true, + declaration: true, + rollup: { + emitCJS: true, + }, + entries: [ + { input: 'src/index' }, + { input: 'src/vue/index', name: 'vue' }, + { input: 'src/legacy', name: 'legacy' }, + { input: 'src/vue-legacy', name: 'vue-legacy' }, + ], + externals: [ + 'vue', + '@vue/runtime-core', + 'unplugin-vue-components', + 'unhead', + '@unhead/vue', + '@unhead/schema', + 'vite', + 'vue-router', + '@unhead/vue', + '@unhead/schema', + 'unplugin-ast', + 'unplugin', + 'unplugin-vue-components', + 'vue', + '@vue/runtime-core', + ], +}) diff --git a/packages/scripts/legacy.d.ts b/packages/scripts/legacy.d.ts new file mode 100644 index 00000000..39833cfa --- /dev/null +++ b/packages/scripts/legacy.d.ts @@ -0,0 +1 @@ +export * from './dist/legacy' diff --git a/packages/scripts/overrides.d.ts b/packages/scripts/overrides.d.ts new file mode 100644 index 00000000..62a57efe --- /dev/null +++ b/packages/scripts/overrides.d.ts @@ -0,0 +1,9 @@ +declare module '@unhead/schema' { + import type { ScriptInstance } from '@unhead/scripts' + + export interface HeadHooks { + 'script:updated': (ctx: { script: ScriptInstance }) => void | Promise + } +} + +export {} diff --git a/packages/scripts/package.json b/packages/scripts/package.json new file mode 100644 index 00000000..3c3eab1e --- /dev/null +++ b/packages/scripts/package.json @@ -0,0 +1,97 @@ +{ + "name": "@unhead/scripts", + "type": "module", + "version": "1.11.14", + "description": "Unhead Scripts allows you to load third-party scripts with better performance, privacy, and security.", + "author": "Harlan Wilton ", + "license": "MIT", + "funding": "https://github.com/sponsors/harlan-zw", + "homepage": "https://unhead.unjs.io", + "repository": { + "type": "git", + "url": "git+https://github.com/unjs/unhead.git", + "directory": "packages/schema-org" + }, + "bugs": { + "url": "https://github.com/unjs/unhead/issues" + }, + "keywords": [ + "schema.org", + "node", + "seo" + ], + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./vue": { + "types": "./dist/vue.d.ts", + "import": "./dist/vue.mjs", + "require": "./dist/vue.cjs" + }, + "./legacy": { + "types": "./dist/legacy.d.ts", + "import": "./dist/legacy.mjs", + "require": "./dist/legacy.cjs" + }, + "./vue-legacy": { + "types": "./dist/vue-legacy.d.ts", + "import": "./dist/vue-legacy.mjs", + "require": "./dist/vue-legacy.cjs" + } + }, + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "typesVersions": { + "*": { + "vue": [ + "dist/vue" + ], + "legacy": [ + "dist/legacy" + ], + "vue-legacy": [ + "dist/vue-legacy" + ] + } + }, + "files": [ + "dist", + "legacy.d.ts", + "overrides.d.ts", + "vue.d.ts" + ], + "scripts": { + "build": "unbuild .", + "stub": "unbuild . --stub", + "test": "vitest", + "release": "bumpp package.json --commit --push --tag", + "lint": "eslint \"{src,test}/**/*.{ts,vue,json,yml}\" --fix" + }, + "peerDependencies": { + "@unhead/shared": "workspace:*", + "@unhead/vue": "workspace:*", + "unhead": "workspace:*" + }, + "peerDependenciesMeta": { + "@unhead/vue": { + "optional": true + } + }, + "build": { + "external": [ + "vue" + ] + }, + "devDependencies": { + "@unhead/schema": "workspace:*", + "@unhead/shared": "workspace:*", + "@unhead/vue": "workspace:*", + "unhead": "workspace:*", + "unplugin-vue-components": "^0.27.5" + } +} diff --git a/packages/scripts/src/index.ts b/packages/scripts/src/index.ts new file mode 100644 index 00000000..a0eae045 --- /dev/null +++ b/packages/scripts/src/index.ts @@ -0,0 +1,3 @@ +export * from './proxy' +export * from './types' +export * from './useScript' diff --git a/packages/scripts/src/legacy.ts b/packages/scripts/src/legacy.ts new file mode 100644 index 00000000..e20c624e --- /dev/null +++ b/packages/scripts/src/legacy.ts @@ -0,0 +1,95 @@ +import type { UseScriptOptions as CurrentUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptInput } from './types' +import { useUnhead } from 'unhead' +import { useScript as _useScript } from './useScript' + +export interface UseScriptOptions> extends CurrentUseScriptOptions { + /** + * Stub the script instance. Useful for SSR or testing. + */ + stub?: ((ctx: { script: ScriptInstance, fn: string | symbol }) => any) +} + +type BaseScriptApi = Record + +export type AsAsyncFunctionValues = { + [key in keyof T]: + T[key] extends any[] ? T[key] : + T[key] extends (...args: infer A) => infer R ? (...args: A) => R extends Promise ? R : Promise : + T[key] extends Record ? AsAsyncFunctionValues : + never +} + +export type UseScriptContext> = + (Promise & ScriptInstance) + & AsAsyncFunctionValues + & { + /** + * @deprecated Use top-level functions instead. + */ + $script: Promise & ScriptInstance + } + +const ScriptProxyTarget = Symbol('ScriptProxyTarget') +function scriptProxy() {} +scriptProxy[ScriptProxyTarget] = true + +export function useScript = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { + const head = _options?.head || useUnhead() + const script = _useScript(_input, _options) as any as UseScriptContext + // support deprecated behavior + script.$script = script + const proxyChain = (instance: any, accessor?: string | symbol, accessors?: (string | symbol)[]) => { + return new Proxy((!accessor ? instance : instance?.[accessor]) || scriptProxy, { + get(_, k, r) { + // @ts-expect-error untyped + head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ }) + if (!accessor) { + const stub = _options?.stub?.({ script, fn: k }) + if (stub) + return stub + } + if (_ && k in _ && typeof _[k] !== 'undefined') { + return Reflect.get(_, k, r) + } + if (k === Symbol.iterator) { + return [][Symbol.iterator] + } + return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k]) + }, + async apply(_, _this, args) { + // we are faking, just return, avoid promise handles + if (head.ssr && _[ScriptProxyTarget]) + return + let instance: any + const access = (fn?: T) => { + instance = fn || instance + for (let i = 0; i < (accessors || []).length; i++) { + const k = (accessors || [])[i] + fn = fn?.[k] + } + return fn + } + let fn = access(script.instance) + if (!fn) { + fn = await (new Promise((resolve) => { + script.onLoaded((api) => { + resolve(access(api)) + }) + })) + } + return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn + }, + }) + } + script.proxy = proxyChain(script.instance) + return new Proxy(Object.assign(script._loadPromise, script), { + get(_, k) { + // _ keys are reserved for internal overrides + const target = (k in script || String(k)[0] === '_') ? script : script.proxy + if (k === 'then' || k === 'catch') { + return script[k].bind(script) + } + return Reflect.get(target, k, target) + }, + }) +} diff --git a/packages/scripts/src/proxy.ts b/packages/scripts/src/proxy.ts new file mode 100644 index 00000000..f797504f --- /dev/null +++ b/packages/scripts/src/proxy.ts @@ -0,0 +1,67 @@ +import type { AsVoidFunctions, RecordingEntry } from './types' + +export function createNoopedRecordingProxy>(instance: T = {} as T): { proxy: AsVoidFunctions, stack: RecordingEntry[][] } { + const stack: RecordingEntry[][] = [] + + let stackIdx = -1 + const handler = (reuseStack = false) => ({ + get(_, prop, receiver) { + if (!reuseStack) { + const v = Reflect.get(_, prop, receiver) + if (typeof v !== 'undefined') { + return v + } + stackIdx++ // root get triggers a new stack + stack[stackIdx] = [] + } + stack[stackIdx].push({ type: 'get', key: prop }) + // @ts-expect-error untyped + return new Proxy(() => {}, handler(true)) + }, + apply(_, __, args) { + stack[stackIdx].push({ type: 'apply', key: '', args }) + return undefined + }, + } as ProxyHandler) + + return { + proxy: new Proxy(instance || {}, handler()), + stack, + } +} + +export function createForwardingProxy>(target: T): AsVoidFunctions { + const handler: ProxyHandler = { + get(_, prop, receiver) { + const v = Reflect.get(_, prop, receiver) + if (typeof v === 'object') { + return new Proxy(v, handler) + } + return v + }, + apply(_, __, args) { + // does not return the apply output for consistency + // @ts-expect-error untyped + Reflect.apply(_, __, args) + return undefined + }, + } + return new Proxy(target, handler) as AsVoidFunctions +} + +export function replayProxyRecordings(target: T, stack: RecordingEntry[][]) { + stack.forEach((recordings) => { + let context: any = target + let prevContext: any = target + recordings.forEach(({ type, key, args }) => { + if (type === 'get') { + prevContext = context + context = context[key] + } + else if (type === 'apply') { + // @ts-expect-error untyped + context = (context as () => any).call(prevContext, ...args) + } + }) + }) +} diff --git a/packages/schema/src/script.ts b/packages/scripts/src/types.ts similarity index 65% rename from packages/schema/src/script.ts rename to packages/scripts/src/types.ts index 0bf31047..ee2285fb 100644 --- a/packages/schema/src/script.ts +++ b/packages/scripts/src/types.ts @@ -1,8 +1,8 @@ -import type { ActiveHeadEntry, HeadEntryOptions } from './head' -import type { Script } from './schema' +import type { ActiveHeadEntry, HeadEntryOptions, Script } from '@unhead/schema' export type UseScriptStatus = 'awaitingLoad' | 'loading' | 'loaded' | 'error' | 'removed' +export type UseScriptContext> = ScriptInstance /** * Either a string source for the script or full script properties. */ @@ -10,26 +10,41 @@ export type UseScriptInput = string | (Omit & { src: string }) export type UseScriptResolvedInput = Omit & { src: string } type BaseScriptApi = Record -export type AsAsyncFunctionValues = { +export type AsVoidFunctions = { [key in keyof T]: T[key] extends any[] ? T[key] : - T[key] extends (...args: infer A) => infer R ? (...args: A) => R extends Promise ? R : Promise : - T[key] extends Record ? AsAsyncFunctionValues : + T[key] extends (...args: infer A) => any ? (...args: A) => void : + T[key] extends Record ? AsVoidFunctions : never } +export type UseFunctionType = T extends { + use: infer V +} ? V extends (...args: any) => any ? ReturnType : U : U + +export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' + export interface ScriptInstance { - proxy: AsAsyncFunctionValues + proxy: AsVoidFunctions instance?: T id: string - status: UseScriptStatus + status: Readonly entry?: ActiveHeadEntry load: () => Promise + warmup: (rel: WarmupStrategy) => ActiveHeadEntry remove: () => boolean setupTriggerHandler: (trigger: UseScriptOptions['trigger']) => void // cbs - onLoaded: (fn: (instance: T) => void | Promise) => void - onError: (fn: (err?: Error) => void | Promise) => void + onLoaded: (fn: (instance: T) => void | Promise, options?: EventHandlerOptions) => void + onError: (fn: (err?: Error) => void | Promise, options?: EventHandlerOptions) => void + /** + * @internal + */ + _loadPromise: Promise + /** + * @internal + */ + _warmupEl: any /** * @internal */ @@ -51,19 +66,22 @@ export interface ScriptInstance { } } -export type UseFunctionType = T extends { - use: infer V -} ? V extends (...args: any) => any ? ReturnType : U : U +export interface EventHandlerOptions { + /** + * Used to dedupe the event, allowing you to have an event run only a single time. + */ + key?: string +} + +export type RecordingEntry = + | { type: 'get', key: string | symbol, args?: any[], value?: any } + | { type: 'apply', key: string | symbol, args: any[] } export interface UseScriptOptions> extends HeadEntryOptions { /** * Resolve the script instance from the window. */ use?: () => T | undefined | null - /** - * Stub the script instance. Useful for SSR or testing. - */ - stub?: ((ctx: { script: ScriptInstance, fn: string | symbol }) => any) /** * The trigger to load the script: * - `undefined` | `client` - (Default) Load the script on the client when this js is loaded. @@ -73,6 +91,10 @@ export interface UseScriptOptions> * - `server` - Have the script injected on the server. */ trigger?: 'client' | 'server' | 'manual' | Promise | ((fn: any) => any) | null + /** + * Add a preload or preconnect link tag before the script is loaded. + */ + warmupStrategy?: WarmupStrategy /** * Context to run events with. This is useful in Vue to attach the current instance context before * calling the event, allowing the event to be reactive. diff --git a/packages/unhead/src/composables/useScript.ts b/packages/scripts/src/useScript.ts similarity index 66% rename from packages/unhead/src/composables/useScript.ts rename to packages/scripts/src/useScript.ts index c2c13d04..25dee956 100644 --- a/packages/unhead/src/composables/useScript.ts +++ b/packages/scripts/src/useScript.ts @@ -1,39 +1,32 @@ import type { - AsAsyncFunctionValues, Head, +} from '@unhead/schema' +import type { + EventHandlerOptions, ScriptInstance, UseFunctionType, + UseScriptContext, UseScriptInput, UseScriptOptions, UseScriptResolvedInput, -} from '@unhead/schema' + WarmupStrategy, +} from './types' import { hashCode, ScriptNetworkEvents } from '@unhead/shared' -import { useUnhead } from '../context' - -export type UseScriptContext> = - (Promise & ScriptInstance) - & AsAsyncFunctionValues - & { - /** - * @deprecated Use top-level functions instead. - */ - $script: Promise & ScriptInstance - } - -const ScriptProxyTarget = Symbol('ScriptProxyTarget') -function scriptProxy() {} -scriptProxy[ScriptProxyTarget] = true +import { useUnhead } from 'unhead' +import { createForwardingProxy, createNoopedRecordingProxy, replayProxyRecordings } from './proxy' export function resolveScriptKey(input: UseScriptResolvedInput) { return input.key || hashCode(input.src || (typeof input.innerHTML === 'string' ? input.innerHTML : '')) } +const PreconnectServerModes = ['preconnect', 'dns-prefetch'] + /** * Load third-party scripts with SSR support and a proxied API. * * @see https://unhead.unjs.io/usage/composables/use-script */ -export function useScript = Record, U = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { +export function useScript = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { const input: UseScriptResolvedInput = typeof _input === 'string' ? { src: _input } : _input const options = _options || {} const head = options.head || useUnhead() @@ -60,7 +53,15 @@ export function useScript = Record['_cbs'] = { loaded: [], error: [] } - const _registerCb = (key: 'loaded' | 'error', cb: any) => { + const _uniqueCbs: Set = new Set() + const _registerCb = (key: 'loaded' | 'error', cb: any, options?: EventHandlerOptions) => { + if (options?.key) { + const key = `${options?.key}:${options.key}` + if (_uniqueCbs.has(key)) { + return + } + _uniqueCbs.add(key) + } if (_cbs[key]) { const i: number = _cbs[key].push(cb) return () => _cbs[key]?.splice(i - 1, 1) @@ -98,15 +99,18 @@ export function useScript = Record>> { + const script = { + _loadPromise: loadPromise, instance: (!head.ssr && options?.use?.()) || null, proxy: null, id, status: 'awaitingLoad', + remove() { // cancel any pending triggers as we've started loading script._triggerAbortController?.abort() script._triggerPromises = [] // clear any pending promises + script._warmupEl?.dispose() if (script.entry) { script.entry.dispose() script.entry = undefined @@ -116,6 +120,31 @@ export function useScript = Record['link'][0] = { + href, + rel, + crossorigin: input.crossorigin || isCrossOrigin ? 'anonymous' : undefined, + referrerpolicy: input.referrerpolicy || isCrossOrigin ? 'no-referrer' : undefined, + fetchpriority: input.fetchpriority || 'low', + integrity: input.integrity, + as: rel === 'preload' ? 'script' : undefined, + } + // @ts-expect-error untyped + script._warmupEl = head.push({ link: [link] }, { head, tagPriority: 'high' }) + return script._warmupEl + }, load(cb?: () => void | Promise) { // cancel any pending triggers as we've started loading script._triggerAbortController?.abort() @@ -140,11 +169,11 @@ export function useScript = Record void | Promise) { - return _registerCb('loaded', cb) + onLoaded(cb: (instance: T) => void | Promise, options?: EventHandlerOptions) { + return _registerCb('loaded', cb, options) }, - onError(cb: (err?: Error) => void | Promise) { - return _registerCb('error', cb) + onError(cb: (err?: Error) => void | Promise, options?: EventHandlerOptions) { + return _registerCb('error', cb, options) }, setupTriggerHandler(trigger: UseScriptOptions['trigger']) { if (script.status !== 'awaitingLoad') { @@ -187,7 +216,7 @@ export function useScript = Record + } as any as UseScriptContext // script is ready loadPromise .then((api) => { @@ -204,62 +233,22 @@ export function useScript = Record { - return new Proxy((!accessor ? instance : instance?.[accessor]) || scriptProxy, { - get(_, k, r) { - head.hooks.callHook('script:instance-fn', { script, fn: k, exists: k in _ }) - if (!accessor) { - const stub = options.stub?.({ script, fn: k }) - if (stub) - return stub - } - if (_ && k in _ && typeof _[k] !== 'undefined') { - return Reflect.get(_, k, r) - } - if (k === Symbol.iterator) { - return [][Symbol.iterator] - } - return proxyChain(accessor ? instance?.[accessor] : instance, k, accessors || [k]) - }, - async apply(_, _this, args) { - // we are faking, just return, avoid promise handles - if (head.ssr && _[ScriptProxyTarget]) - return - let instance: any - const access = (fn?: T) => { - instance = fn || instance - for (let i = 0; i < (accessors || []).length; i++) { - const k = (accessors || [])[i] - fn = fn?.[k] - } - return fn - } - let fn = access(script.instance) - if (!fn) { - fn = await (new Promise((resolve) => { - script.onLoaded((api) => { - resolve(access(api)) - }) - })) - } - return typeof fn === 'function' ? Reflect.apply(fn, instance, args) : fn - }, + if (options.use) { + const { proxy, stack } = createNoopedRecordingProxy(options.use() || {} as T) + script.proxy = proxy + script.onLoaded((instance) => { + replayProxyRecordings(instance, stack) + // just forward everything with the same behavior + script.proxy = createForwardingProxy(instance) }) } - script.proxy = proxyChain(script.instance) - // remove in v2, just return the script - const res = new Proxy(script, { - get(_, k) { - // _ keys are reserved for internal overrides - const target = (k in script || String(k)[0] === '_') ? script : script.proxy - if (k === 'then' || k === 'catch') { - return script[k].bind(script) - } - return Reflect.get(target, k, target) - }, - }) - head._scripts = Object.assign(head._scripts || {}, { [id]: res }) - return res + // need to make sure it's not already registered + if (!options.warmupStrategy && (typeof options.trigger === 'undefined' || options.trigger === 'client')) { + options.warmupStrategy = 'preload' + } + if (options.warmupStrategy) { + script.warmup(options.warmupStrategy) + } + head._scripts = Object.assign(head._scripts || {}, { [id]: script }) + return script } diff --git a/packages/scripts/src/utils.ts b/packages/scripts/src/utils.ts new file mode 100644 index 00000000..4f833e9e --- /dev/null +++ b/packages/scripts/src/utils.ts @@ -0,0 +1,31 @@ +import type { RecordingEntry } from './types' + +export function createSpyProxy | any[]>(target: T, onApply: (stack: RecordingEntry[][]) => void): T { + const stack: RecordingEntry[][] = [] + + let stackIdx = -1 + const handler = (reuseStack = false) => ({ + get(_, prop, receiver) { + if (!reuseStack) { + stackIdx++ // root get triggers a new stack + stack[stackIdx] = [] + } + const v = Reflect.get(_, prop, receiver) + if (typeof v === 'object' || typeof v === 'function') { + stack[stackIdx].push({ type: 'get', key: prop }) + // @ts-expect-error untyped + return new Proxy(v, handler(true)) + } + stack[stackIdx].push({ type: 'get', key: prop, value: v }) + return v + }, + apply(_, __, args) { + stack[stackIdx].push({ type: 'apply', key: '', args }) + onApply(stack) + // @ts-expect-error untyped + return Reflect.apply(_, __, args) + }, + } as ProxyHandler) + + return new Proxy(target, handler()) +} diff --git a/packages/scripts/src/vue-legacy.ts b/packages/scripts/src/vue-legacy.ts new file mode 100644 index 00000000..62c8ce99 --- /dev/null +++ b/packages/scripts/src/vue-legacy.ts @@ -0,0 +1,109 @@ +import type { + DataKeys, + HeadEntryOptions, + SchemaAugmentations, + ScriptBase, +} from '@unhead/schema' +import type { MaybeComputedRefEntriesOnly } from '@unhead/vue' +import type { ComponentInternalInstance, Ref, WatchHandle } from 'vue' +import type { UseScriptOptions as BaseUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptStatus } from './types' +import { injectHead } from '@unhead/vue' +import { getCurrentInstance, isRef, onMounted, onScopeDispose, ref, watch } from 'vue' +import { useScript as _useScript } from './legacy' + +export interface VueScriptInstance> extends Omit, 'status'> { + status: Ref +} + +export type UseScriptInput = string | (MaybeComputedRefEntriesOnly> & { src: string }) +export interface UseScriptOptions = Record> extends HeadEntryOptions, Pick, 'use' | 'eventContext' | 'beforeInit'> { + /** + * The trigger to load the script: + * - `undefined` | `client` - (Default) Load the script on the client when this js is loaded. + * - `manual` - Load the script manually by calling `$script.load()`, exists only on the client. + * - `Promise` - Load the script when the promise resolves, exists only on the client. + * - `Function` - Register a callback function to load the script, exists only on the client. + * - `server` - Have the script injected on the server. + * - `ref` - Load the script when the ref is true. + */ + trigger?: BaseUseScriptOptions['trigger'] | Ref +} + +export type UseScriptContext> = Promise & VueScriptInstance + +function registerVueScopeHandlers = Record>(script: UseScriptContext, T>>, scope?: ComponentInternalInstance | null) { + if (!scope) { + return + } + const _registerCb = (key: 'loaded' | 'error', cb: any) => { + if (!script._cbs[key]) { + cb(script.instance) + return () => {} + } + let i: number | null = script._cbs[key].push(cb) + const destroy = () => { + // avoid removing the wrong callback + if (i) { + script._cbs[key]?.splice(i - 1, 1) + i = null + } + } + onScopeDispose(destroy) + return destroy + } + // if we have a scope we should make these callbacks reactive + script.onLoaded = (cb: (instance: T) => void | Promise) => _registerCb('loaded', cb) + script.onError = (cb: (err?: Error) => void | Promise) => _registerCb('error', cb) + onScopeDispose(() => { + // stop any trigger promises + script._triggerAbortController?.abort() + }) +} + +export function useScript = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { + const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput + const options = _options || {} as UseScriptOptions + const head = options?.head || injectHead() + // @ts-expect-error untyped + options.head = head + const scope = getCurrentInstance() + options.eventContext = scope + if (scope && typeof options.trigger === 'undefined') { + options.trigger = onMounted + } + else if (isRef(options.trigger)) { + const refTrigger = options.trigger as Ref + let off: WatchHandle + options.trigger = new Promise((resolve) => { + off = watch(refTrigger, (val) => { + if (val) { + resolve(true) + } + }, { + immediate: true, + }) + onScopeDispose(() => resolve(false), true) + }).then((val) => { + off?.() + return val + }) + } + // we may be re-using an existing script + // sync the status, need to register before useScript + // @ts-expect-error untyped + head._scriptStatusWatcher = head._scriptStatusWatcher || head.hooks.hook('script:updated', ({ script: s }) => { + s._statusRef.value = s.status + }) + // @ts-expect-error untyped + const script = _useScript(input as BaseUseScriptInput, options) + script._statusRef = script._statusRef || ref(script.status) + // Note: we don't remove scripts on unmount as it's not a common use case and reloading the script may be expensive + // @ts-expect-error untyped + registerVueScopeHandlers(script, scope) + return new Proxy(script, { + get(_, key, a) { + // we can't override status as it will break the unhead useScript API + return Reflect.get(_, key === 'status' ? '_statusRef' : key, a) + }, + }) as any as UseScriptContext, T>> +} diff --git a/packages/scripts/src/vue/index.ts b/packages/scripts/src/vue/index.ts new file mode 100644 index 00000000..52f05aff --- /dev/null +++ b/packages/scripts/src/vue/index.ts @@ -0,0 +1 @@ +export * from './useScript' diff --git a/packages/vue/src/composables/useScript.ts b/packages/scripts/src/vue/useScript.ts similarity index 78% rename from packages/vue/src/composables/useScript.ts rename to packages/scripts/src/vue/useScript.ts index e69fc806..3c65d214 100644 --- a/packages/vue/src/composables/useScript.ts +++ b/packages/scripts/src/vue/useScript.ts @@ -1,28 +1,22 @@ import type { - AsAsyncFunctionValues, - UseScriptInput as BaseUseScriptInput, - UseScriptOptions as BaseUseScriptOptions, DataKeys, HeadEntryOptions, SchemaAugmentations, ScriptBase, - ScriptInstance, - UseFunctionType, - UseScriptResolvedInput, - UseScriptStatus, } from '@unhead/schema' +import type { MaybeComputedRefEntriesOnly } from '@unhead/vue' import type { ComponentInternalInstance, Ref, WatchHandle } from 'vue' -import type { MaybeComputedRefEntriesOnly } from '../types' -import { useScript as _useScript } from 'unhead' +import type { UseScriptOptions as BaseUseScriptOptions, ScriptInstance, UseFunctionType, UseScriptStatus } from '../types' +import { injectHead } from '@unhead/vue' import { getCurrentInstance, isRef, onMounted, onScopeDispose, ref, watch } from 'vue' -import { injectHead } from './injectHead' +import { useScript as _useScript } from '../useScript' export interface VueScriptInstance> extends Omit, 'status'> { status: Ref } export type UseScriptInput = string | (MaybeComputedRefEntriesOnly> & { src: string }) -export interface UseScriptOptions = Record, U = Record> extends HeadEntryOptions, Pick, 'use' | 'stub' | 'eventContext' | 'beforeInit'> { +export interface UseScriptOptions = Record> extends HeadEntryOptions, Pick, 'use' | 'eventContext' | 'beforeInit'> { /** * The trigger to load the script: * - `undefined` | `client` - (Default) Load the script on the client when this js is loaded. @@ -35,17 +29,9 @@ export interface UseScriptOptions = Recor trigger?: BaseUseScriptOptions['trigger'] | Ref } -export type UseScriptContext> = - (Promise & VueScriptInstance) - & AsAsyncFunctionValues - & { - /** - * @deprecated Use top-level functions instead. - */ - $script: Promise & VueScriptInstance - } +export type UseScriptContext> = Promise & VueScriptInstance -function registerVueScopeHandlers = Record>(script: UseScriptContext, T>>, scope?: ComponentInternalInstance | null) { +function registerVueScopeHandlers = Record>(script: UseScriptContext, T>>, scope?: ComponentInternalInstance | null) { if (!scope) { return } @@ -74,9 +60,9 @@ function registerVueScopeHandlers = Recor }) } -export function useScript = Record, U = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { - const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptResolvedInput - const options = _options || {} +export function useScript = Record>(_input: UseScriptInput, _options?: UseScriptOptions): UseScriptContext, T>> { + const input = (typeof _input === 'string' ? { src: _input } : _input) as UseScriptInput + const options = _options || {} as UseScriptOptions const head = options?.head || injectHead() // @ts-expect-error untyped options.head = head @@ -106,7 +92,6 @@ export function useScript = Record { - // @ts-expect-error untyped s._statusRef.value = s.status }) // @ts-expect-error untyped @@ -121,5 +106,5 @@ export function useScript = Record, T>> + }) as any as UseScriptContext, T>> } diff --git a/test/unhead/dom/useScript.test.ts b/packages/scripts/test/unit/dom.test.ts similarity index 56% rename from test/unhead/dom/useScript.test.ts rename to packages/scripts/test/unit/dom.test.ts index a4a48da7..2545ec4b 100644 --- a/test/unhead/dom/useScript.test.ts +++ b/packages/scripts/test/unit/dom.test.ts @@ -1,54 +1,40 @@ -import { useScript } from 'unhead' import { describe, it } from 'vitest' -import { useDelayedSerializedDom, useDOMHead } from './util' +import { useDelayedSerializedDom, useDOMHead } from '../../../../test/unhead/dom/util' +import { useScript } from '../../src/useScript' describe('dom useScript', () => { it('basic', async () => { - const head = useDOMHead() + useDOMHead() - const instance = useScript<{ test: (foo: string) => void }>({ + let calledFn + const instance = useScript({ src: 'https://cdn.example.com/script.js', }, { use() { return { - test: () => {}, + test: () => { + calledFn = 'test' + return 'foo' + }, } }, }) - expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(` - " - - - - -
-

hello world

-
- - - - " + expect((await useDelayedSerializedDom()).split('\n').filter(l => l.startsWith('", + ] `) - let calledFn - const hookPromise = new Promise((resolve) => { - head.hooks.hook('script:instance-fn', ({ script, fn }) => { - if (script.id === instance.$script.id) { - calledFn = fn - resolve() - } - }) - }) - instance.test('hello-world') - await hookPromise + instance.proxy.test('hello-world') expect(calledFn).toBe('test') }) it('proxy', async () => { - useDOMHead() + const head = useDOMHead() const instance = useScript<{ test: (foo: string) => string }>({ src: 'https://cdn.example.com/script.js', + head, }, { use() { return { @@ -57,7 +43,7 @@ describe('dom useScript', () => { }, }) - expect(await instance.proxy.test('hello-world')).toEqual('hello-world') + expect(instance.proxy.test('hello-world')).toEqual('hello-world') }) it('remove & re-add', async () => { useDOMHead() @@ -67,11 +53,7 @@ describe('dom useScript', () => { }) let dom = await useDelayedSerializedDom() - expect(dom.split('\n').filter(l => l.trim().startsWith('", - ] - `) + expect(dom.split('\n').filter(l => l.trim().startsWith(' setTimeout(r, 100)) diff --git a/test/unhead/e2e/scripts.test.ts b/packages/scripts/test/unit/e2e.test.ts similarity index 92% rename from test/unhead/e2e/scripts.test.ts rename to packages/scripts/test/unit/e2e.test.ts index 6d59bf43..43cead19 100644 --- a/test/unhead/e2e/scripts.test.ts +++ b/packages/scripts/test/unit/e2e.test.ts @@ -1,15 +1,16 @@ import { renderDOMHead } from '@unhead/dom' import { renderSSRHead } from '@unhead/ssr' -import { useHead, useScript } from 'unhead' +import { useHead } from 'unhead' import { describe, it } from 'vitest' -import { useDom } from '../../fixtures' -import { createHeadWithContext } from '../../util' +import { useDom } from '../../../../test/fixtures' +import { createHeadWithContext, createServerHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' describe('unhead e2e scripts', () => { it('does not duplicate innerHTML', async () => { // scenario: we are injecting root head schema which will not have a hydration step, // but we are also injecting a child head schema which will have a hydration step - const ssrHead = createHeadWithContext() + const ssrHead = createServerHeadWithContext() const input = { script: [ { diff --git a/packages/scripts/test/unit/events.test.ts b/packages/scripts/test/unit/events.test.ts new file mode 100644 index 00000000..c40ed5b1 --- /dev/null +++ b/packages/scripts/test/unit/events.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { describe, it } from 'vitest' +import { createHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' + +describe('useScript events', () => { + it('simple', async () => { + createHeadWithContext() + const instance = useScript('/script.js', { + trigger: 'server', + }) + expect(await new Promise((resolve) => { + instance.status = 'loaded' + instance.onLoaded(() => { + resolve(true) + }) + })).toBeTruthy() + }) + it('dedupe', async () => { + createHeadWithContext() + const instance = useScript('/script.js', { + trigger: 'server', + }) + const calls: any[] = [] + instance.onLoaded(() => { + calls.push('a') + }, { + key: 'once', + }) + instance.onLoaded(() => { + calls.push('b') + }, { + key: 'once', + }) + instance.status = 'loaded' + await new Promise((resolve) => { + instance.onLoaded(() => { + calls.push('c') + resolve() + }) + }) + expect(calls).toMatchInlineSnapshot(` + [ + "a", + "c", + ] + `) + }) +}) diff --git a/packages/scripts/test/unit/proxy.test.ts b/packages/scripts/test/unit/proxy.test.ts new file mode 100644 index 00000000..d15d60bc --- /dev/null +++ b/packages/scripts/test/unit/proxy.test.ts @@ -0,0 +1,158 @@ +import type { AsVoidFunctions } from '../../src' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { createServerHeadWithContext } from '../../../../test/util' +import { createForwardingProxy } from '../../src' +import { createNoopedRecordingProxy, replayProxyRecordings } from '../../src/proxy' +import { useScript } from '../../src/useScript' +import { createSpyProxy } from '../../src/utils' + +interface Api { + _paq: any[] + doSomething: () => Promise<'foo'> + say: (message: string) => string + foo: { + bar: { + fn: () => true + } + } +} + +describe('proxy chain', () => { + it('augments types', () => { + const proxy = createNoopedRecordingProxy() + expectTypeOf(proxy.proxy._paq).toBeArray() + expectTypeOf(proxy.proxy.doSomething).toBeFunction() + expectTypeOf(proxy.proxy.doSomething).returns.toBeVoid() + expectTypeOf(proxy.proxy.say).parameter(0).toBeString() + expectTypeOf(proxy.proxy.foo.bar.fn).toBeFunction() + }) + it('e2e', async () => { + // do recording + const { proxy, stack } = createNoopedRecordingProxy() + const script = { proxy, instance: null } + script.proxy._paq.push(['test']) + script.proxy.say('hello world') + expect(stack.length).toBe(2) + let called + const w: any = { + _paq: createSpyProxy([], () => { + called = true + }), + say: (s: string) => { + console.log(s) + return s + }, + } + // did load + script.instance = { + _paq: w._paq, + say: w.say, + } + const log = console.log + // replay recording + const consoleMock = vi.spyOn(console, 'log').mockImplementation((...args) => { + log('mocked', ...args) + }) + replayProxyRecordings(script.instance, stack) + // @ts-expect-error untyped + script.proxy = createForwardingProxy(script.instance) + expect(consoleMock).toHaveBeenCalledWith('hello world') + script.proxy.say('proxy updated!') + expect(consoleMock).toHaveBeenCalledWith('proxy updated!') + expect(script.instance).toMatchInlineSnapshot(` + { + "_paq": [ + [ + "test", + ], + ], + "say": [Function], + } + `) + script.proxy._paq.push(['test']) + consoleMock.mockReset() + expect(called).toBe(true) + }) + it('spy', () => { + const w: any = {} + w._paq = [] + const stack: any[] = [] + w._paq = createSpyProxy(w._paq, (s) => { + stack.push(s) + }) + w._paq.push(['test']) + expect(stack).toMatchInlineSnapshot(` + [ + [ + [ + { + "key": "push", + "type": "get", + }, + { + "args": [ + [ + "test", + ], + ], + "key": "", + "type": "apply", + }, + ], + [ + { + "key": "length", + "type": "get", + "value": 0, + }, + ], + ], + ] + `) + }) + it('use() provided', () => { + const head = createServerHeadWithContext() + const instance = useScript({ + src: 'https://cdn.example.com/script.js', + head, + }, { + use() { + return { + greet: (foo: string) => { + console.log(foo) + return foo + }, + } + }, + }) + instance.onLoaded((vm) => { + vm.greet('hello-world') + }) + const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined) + expectTypeOf(instance.proxy.greet).toBeFunction() + instance.proxy.greet('hello-world') + expect(consoleMock).toHaveBeenCalledWith('hello-world') + }) +}) + +describe('types: AsVoidFunctions', () => { + it('should keep array properties unchanged', () => { + type Result = AsVoidFunctions + expectTypeOf().toEqualTypeOf() + }) + + it('should convert function properties to void functions', () => { + type Result = AsVoidFunctions + expectTypeOf().toBeFunction() + expectTypeOf().returns.toBeVoid() + expectTypeOf().toBeFunction() + expectTypeOf().parameters.toEqualTypeOf<[string]>() + expectTypeOf().returns.toBeVoid() + }) + + it('should recursively convert nested function properties to void functions', () => { + type Result = AsVoidFunctions + expectTypeOf().toBeFunction() + expectTypeOf().returns.toBeVoid() + }) +}) diff --git a/test/unhead/ssr/useScript.test.ts b/packages/scripts/test/unit/ssr.test.ts similarity index 91% rename from test/unhead/ssr/useScript.test.ts rename to packages/scripts/test/unit/ssr.test.ts index 643234b7..cb4c0592 100644 --- a/test/unhead/ssr/useScript.test.ts +++ b/packages/scripts/test/unit/ssr.test.ts @@ -1,7 +1,6 @@ import { renderSSRHead } from '@unhead/ssr' -import { useScript } from 'unhead' -import { describe, it } from 'vitest' -import { createHeadWithContext, createServerHeadWithContext } from '../../util' +import { createHeadWithContext, createServerHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' describe('ssr useScript', () => { it('default', async () => { @@ -17,7 +16,7 @@ describe('ssr useScript', () => { "bodyAttrs": "", "bodyTags": "", "bodyTagsOpen": "", - "headTags": "", + "headTags": "", "htmlAttrs": "", } `) @@ -65,7 +64,7 @@ describe('ssr useScript', () => { }) it('google ', async () => { const head = createServerHeadWithContext() - + const window: any = {} const gtag = useScript<{ dataLayer: any[] }>({ src: 'https://www.googletagmanager.com/gtm.js?id=GTM-MNJD4B', }, { diff --git a/packages/scripts/test/unit/use.test.ts b/packages/scripts/test/unit/use.test.ts new file mode 100644 index 00000000..5076d4ca --- /dev/null +++ b/packages/scripts/test/unit/use.test.ts @@ -0,0 +1,22 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createServerHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' + +describe('useScript', () => { + it('types: inferred use()', async () => { + const instance = useScript({ + src: 'https://cdn.example.com/script.js', + }, { + head: createServerHeadWithContext(), + use() { + return { + // eslint-disable-next-line unused-imports/no-unused-vars + test: (foo: string) => 'foo', + } + }, + }) + expectTypeOf(instance.proxy.test).toBeFunction() + expectTypeOf(instance.proxy.test).parameter(0).toBeString() + expectTypeOf(instance.proxy.test).returns.toBeVoid() + }) +}) diff --git a/packages/vue/test/e2e/scripts.test.ts b/packages/scripts/test/unit/vue.test.ts similarity index 96% rename from packages/vue/test/e2e/scripts.test.ts rename to packages/scripts/test/unit/vue.test.ts index 4cec9ce0..dd99e5b5 100644 --- a/packages/vue/test/e2e/scripts.test.ts +++ b/packages/scripts/test/unit/vue.test.ts @@ -1,10 +1,10 @@ -import { useScript } from '@unhead/vue' import { createHead } from '@unhead/vue/client' import { describe, it } from 'vitest' import { ref, watch } from 'vue' import { useDom } from '../../../../test/fixtures' +import { useScript } from '../../src/vue/useScript' -describe('unhead vue e2e scripts', () => { +describe('vue e2e scripts', () => { it('multiple active promise handles', async () => { const dom = useDom() const head = createHead({ diff --git a/packages/scripts/test/unit/warmup.test.ts b/packages/scripts/test/unit/warmup.test.ts new file mode 100644 index 00000000..a66e5064 --- /dev/null +++ b/packages/scripts/test/unit/warmup.test.ts @@ -0,0 +1,48 @@ +import type { LinkBase } from 'zhead' +import { describe, it } from 'vitest' +import { createServerHeadWithContext } from '../../../../test/util' +import { useScript } from '../../src/useScript' + +describe('warmup', () => { + it('server', () => { + const head = createServerHeadWithContext() + useScript('https://cdn.example.com/script.js', { + head, + trigger: 'server', + }) + const entry = head.headEntries()[0]!.input + expect(entry.script[0].src).toBe('https://cdn.example.com/script.js') + expect(entry.link).toBeUndefined() + }) + it('default / client', () => { + const head = createServerHeadWithContext() + useScript('https://cdn.example.com/script.js', { + head, + trigger: 'client', + }) + const link = head.headEntries()[0]!.input!.link![0] as LinkBase + expect(link.href).toEqual('https://cdn.example.com/script.js') + expect(link.rel).toEqual('preload') + }) + it('relative: default / client', () => { + const head = createServerHeadWithContext() + useScript('/script.js', { + head, + trigger: 'client', + }) + const link = head.headEntries()[0]!.input!.link![0] as LinkBase + expect(link.href).toEqual('/script.js') + expect(link.rel).toEqual('preload') + }) + it('absolute: dns-prefetch', () => { + const head = createServerHeadWithContext() + useScript('https://cdn.example.com/script.js', { + head, + trigger: 'client', + warmupStrategy: 'dns-prefetch', + }) + const link = head.headEntries()[0]!.input!.link![0] as LinkBase + expect(link.href).toEqual('https://cdn.example.com') + expect(link.rel).toEqual('dns-prefetch') + }) +}) diff --git a/packages/scripts/vue-legacy.d.ts b/packages/scripts/vue-legacy.d.ts new file mode 100644 index 00000000..39833cfa --- /dev/null +++ b/packages/scripts/vue-legacy.d.ts @@ -0,0 +1 @@ +export * from './dist/legacy' diff --git a/packages/scripts/vue.d.ts b/packages/scripts/vue.d.ts new file mode 100644 index 00000000..81e5e333 --- /dev/null +++ b/packages/scripts/vue.d.ts @@ -0,0 +1 @@ +export * from './dist/vue' diff --git a/packages/unhead/src/index.ts b/packages/unhead/src/index.ts index 0e139c3e..ad3d67c2 100644 --- a/packages/unhead/src/index.ts +++ b/packages/unhead/src/index.ts @@ -2,7 +2,6 @@ export * from './autoImports' export * from './composables/useHead' export * from './composables/useHeadSafe' -export * from './composables/useScript' export * from './composables/useSeoMeta' export * from './composables/useServerHead' export * from './composables/useServerHeadSafe' diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index ba6341b7..629a6c12 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -17,7 +17,6 @@ export * from './composables/injectHead' export * from './composables/useHead' export * from './composables/useHeadSafe' -export * from './composables/useScript' export * from './composables/useSeoMeta' export * from './composables/useServerHead' export * from './composables/useServerHeadSafe' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acc197ce..910d20a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,24 @@ importers: specifier: ^28.0.0 version: 28.0.0(@babel/parser@7.26.3)(@nuxt/kit@3.15.1(magicast@0.3.5)(rollup@4.29.1))(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2)) + packages/scripts: + devDependencies: + '@unhead/schema': + specifier: workspace:* + version: link:../schema + '@unhead/shared': + specifier: workspace:* + version: link:../shared + '@unhead/vue': + specifier: workspace:* + version: link:../vue + unhead: + specifier: workspace:* + version: link:../unhead + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5(@babel/parser@7.26.3)(@nuxt/kit@3.15.1(magicast@0.3.5)(rollup@4.29.1))(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2)) + packages/shared: dependencies: '@unhead/schema': @@ -6493,6 +6511,19 @@ packages: '@vueuse/core': optional: true + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: ^3.5.13 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + unplugin-vue-components@28.0.0: resolution: {integrity: sha512-vYe0wSyqTVhyNFIad1iiGyQGhG++tDOMgohqenMDOAooMJP9vvzCdXTqCVx20A0rCQXFNjgoRbSeDAioLPH36Q==} engines: {node: '>=14'} @@ -15322,6 +15353,26 @@ snapshots: transitivePeerDependencies: - rollup + unplugin-vue-components@0.27.5(@babel/parser@7.26.3)(@nuxt/kit@3.15.1(magicast@0.3.5)(rollup@4.29.1))(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.1.4(rollup@4.29.1) + chokidar: 3.6.0 + debug: 4.4.0(supports-color@9.4.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.17 + minimatch: 9.0.5 + mlly: 1.7.3 + unplugin: 1.16.0 + vue: 3.5.13(typescript@5.7.2) + optionalDependencies: + '@babel/parser': 7.26.3 + '@nuxt/kit': 3.15.1(magicast@0.3.5)(rollup@4.29.1) + transitivePeerDependencies: + - rollup + - supports-color + unplugin-vue-components@28.0.0(@babel/parser@7.26.3)(@nuxt/kit@3.15.1(magicast@0.3.5)(rollup@4.29.1))(rollup@4.29.1)(vue@3.5.13(typescript@5.7.2)): dependencies: '@antfu/utils': 0.7.10