Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function isProxyDisabled(
registry?: NuxtConfigScriptRegistry,
runtimeConfig?: Record<string, any>,
): boolean {
const entry = registry?.[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined
const entry = registry?.[registryKey] as NormalizedRegistryEntry | undefined
if (!entry)
return true
const [input, scriptOptions] = entry
Expand All @@ -143,7 +143,7 @@ export function applyAutoInject(
if (isProxyDisabled(registryKey, registry, runtimeConfig))
return

const entry = registry[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry
const entry = registry[registryKey] as NormalizedRegistryEntry
const input = entry[0]

const rtScripts = runtimeConfig.public?.scripts as Record<string, any> | undefined
Expand Down
22 changes: 19 additions & 3 deletions packages/script/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,25 @@ export type RegistryScriptKey = Exclude<keyof ScriptRegistry, `${string}-npm`>
type RegistryConfigInput<T> = [T] extends [true] ? Record<string, never> : T

export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | (RegistryConfigInput<T> & { trigger?: NuxtUseScriptOptionsSerializable['trigger'] | false, proxy?: boolean, bundle?: boolean, partytown?: boolean, privacy?: ProxyPrivacyInput })
export type NuxtConfigScriptRegistry<T extends keyof ScriptRegistry = keyof ScriptRegistry> = Partial<{
[key in T]: NuxtConfigScriptRegistryEntry<ScriptRegistry[key]>
}> & Record<string & {}, NuxtConfigScriptRegistryEntry<any>>

// Internal mapped type: derives config entry types from ScriptRegistry.
// Excludes the `${string}-npm` pattern since it's covered by the string index signature.
type _NuxtConfigScriptRegistryEntries = {
[K in keyof ScriptRegistry as K extends `${string}-npm` ? never : K]?: NuxtConfigScriptRegistryEntry<ScriptRegistry[K]>
}
Comment on lines +256 to +260

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excluding the ${string}-npm keys from _NuxtConfigScriptRegistryEntries means config entries like registry['foo-npm'] now fall back to the [key: string]: any signature and lose their NpmInput-derived typing. If the intent is to keep npm pattern keys strongly typed, add an explicit ${string}-npm index signature (or include that pattern in the mapped type) with NuxtConfigScriptRegistryEntry<NpmInput> so it doesn’t collapse to any.

Copilot uses AI. Check for mistakes.

// Interface (not intersection) ensures IDE displays specific types for known keys.
// Explicit properties inherited via `extends` always take priority over the index
// signature, making this immune to catch-all type contamination.
// Augmenting ScriptRegistry automatically flows through to this type.
//
// The index signature uses `any` to satisfy TypeScript's constraint that all
// inherited properties must be subtypes of the index type. This is safe because
// in an interface, explicit properties always take priority over the index
// signature for property access.
export interface NuxtConfigScriptRegistry extends _NuxtConfigScriptRegistryEntries {
[key: string]: any
}
Comment on lines +271 to +273

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NuxtConfigScriptRegistry was previously exported as a generic type (NuxtConfigScriptRegistry<T extends keyof ScriptRegistry = ...>). Changing it to a non-generic interface is a type-level breaking change for any downstream code that narrows the registry to a subset of keys. Consider keeping the generic parameter on the interface (and threading it through the mapped type) to preserve the existing public API surface.

Copilot uses AI. Check for mistakes.

export type UseFunctionType<T, U> = T extends {
use: infer V
Expand Down
5 changes: 3 additions & 2 deletions packages/script/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,15 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
for (const [k, c] of Object.entries(config.registry || {})) {
if (c === false)
continue
const [, scriptOptions] = c as [Record<string, any>, any?]
const entry = c as unknown as [Record<string, any>, any?]
const [, scriptOptions] = entry
if (!scriptOptions?.trigger)
continue
const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`)
if (importDefinition) {
resolvedRegistryKeys.push(k)
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
const [input] = c as [Record<string, any>, any?]
const [input] = entry
const opts = { ...scriptOptions }
const triggerResolved = resolveTriggerForTemplate(opts.trigger)
if (triggerResolved) {
Expand Down
71 changes: 65 additions & 6 deletions test/types/types.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ModuleOptions } from '../../packages/script/src/module'
import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp'
import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics'
import type {
NuxtConfigScriptRegistry,
NuxtUseScriptOptions,
RegistryScriptInput,
ScriptRegistry,
Expand All @@ -10,14 +11,72 @@ import type {
import { describe, expectTypeOf, it } from 'vitest'

describe('module options registry', () => {
it('registry entries are typed', () => {
// Check specific registry keys have proper types (not any)
// Using specific keys because the index signature `[key: \`${string}-npm\`]`
// causes `keyof ScriptRegistry` to include template literals which resolve to any
type Registry = NonNullable<ModuleOptions['registry']>
expectTypeOf<Registry['googleAnalytics']>().not.toBeAny()
type Registry = NonNullable<ModuleOptions['registry']>

it('all registry entries are typed, not any', () => {
// Every built-in registry key must resolve to its specific type, not `any`.
// NuxtConfigScriptRegistry is an interface (not an intersection), so explicit
// properties inherited via `extends` always take priority over the index signature.
expectTypeOf<Registry['bingUet']>().not.toBeAny()
expectTypeOf<Registry['blueskyEmbed']>().not.toBeAny()
expectTypeOf<Registry['carbonAds']>().not.toBeAny()
expectTypeOf<Registry['crisp']>().not.toBeAny()
expectTypeOf<Registry['clarity']>().not.toBeAny()
expectTypeOf<Registry['cloudflareWebAnalytics']>().not.toBeAny()
expectTypeOf<Registry['databuddyAnalytics']>().not.toBeAny()
expectTypeOf<Registry['metaPixel']>().not.toBeAny()
expectTypeOf<Registry['fathomAnalytics']>().not.toBeAny()
expectTypeOf<Registry['instagramEmbed']>().not.toBeAny()
expectTypeOf<Registry['plausibleAnalytics']>().not.toBeAny()
expectTypeOf<Registry['googleAdsense']>().not.toBeAny()
expectTypeOf<Registry['googleAnalytics']>().not.toBeAny()
expectTypeOf<Registry['googleMaps']>().not.toBeAny()
expectTypeOf<Registry['googleRecaptcha']>().not.toBeAny()
expectTypeOf<Registry['googleSignIn']>().not.toBeAny()
expectTypeOf<Registry['lemonSqueezy']>().not.toBeAny()
expectTypeOf<Registry['googleTagManager']>().not.toBeAny()
expectTypeOf<Registry['hotjar']>().not.toBeAny()
expectTypeOf<Registry['intercom']>().not.toBeAny()
expectTypeOf<Registry['paypal']>().not.toBeAny()
expectTypeOf<Registry['posthog']>().not.toBeAny()
expectTypeOf<Registry['matomoAnalytics']>().not.toBeAny()
expectTypeOf<Registry['mixpanelAnalytics']>().not.toBeAny()
expectTypeOf<Registry['rybbitAnalytics']>().not.toBeAny()
expectTypeOf<Registry['redditPixel']>().not.toBeAny()
expectTypeOf<Registry['segment']>().not.toBeAny()
expectTypeOf<Registry['stripe']>().not.toBeAny()
expectTypeOf<Registry['tiktokPixel']>().not.toBeAny()
expectTypeOf<Registry['xEmbed']>().not.toBeAny()
expectTypeOf<Registry['xPixel']>().not.toBeAny()
expectTypeOf<Registry['snapchatPixel']>().not.toBeAny()
expectTypeOf<Registry['youtubePlayer']>().not.toBeAny()
expectTypeOf<Registry['vercelAnalytics']>().not.toBeAny()
expectTypeOf<Registry['vimeoPlayer']>().not.toBeAny()
expectTypeOf<Registry['umamiAnalytics']>().not.toBeAny()
expectTypeOf<Registry['gravatar']>().not.toBeAny()
expectTypeOf<Registry['npm']>().not.toBeAny()
})

it('known keys resolve to their exact entry type, not the catch-all', () => {
// Known registry keys must resolve to NuxtConfigScriptRegistryEntry<SpecificInput>,
// not the index signature's `any` catch-all.
// The interface approach guarantees this: inherited properties from `extends` always
// take priority over the index signature.
type GoogleMapsEntry = NuxtConfigScriptRegistry['googleMaps']
type CatchAllEntry = NuxtConfigScriptRegistry[string]
// Known key must NOT equal the catch-all
expectTypeOf<GoogleMapsEntry>().not.toEqualTypeOf<CatchAllEntry>()

// Verify specific input properties survive (not collapsed to unknown)
type ObjectForm<K extends keyof Registry> = Exclude<Registry[K], boolean | 'mock' | undefined>
expectTypeOf<ObjectForm<'googleMaps'>['apiKey']>().not.toBeNever()
expectTypeOf<ObjectForm<'googleAnalytics'>['id']>().not.toBeNever()
expectTypeOf<ObjectForm<'clarity'>['id']>().not.toBeNever()
})

it('registry allows unknown keys as catch-all', () => {
// Unknown keys fall through to the index signature (any), so custom scripts work
expectTypeOf<Registry['my-custom-script']>().toBeAny()
})
})

Expand Down
Loading