Skip to content

Commit afbb6bd

Browse files
harlan-zwCopilot
andauthored
fix(types): broken IDE display of registry types (#683)
Co-authored-by: Copilot <copilot@github.com>
1 parent a44e8c9 commit afbb6bd

File tree

4 files changed

+89
-13
lines changed

4 files changed

+89
-13
lines changed

packages/script/src/module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function isProxyDisabled(
119119
registry?: NuxtConfigScriptRegistry,
120120
runtimeConfig?: Record<string, any>,
121121
): boolean {
122-
const entry = registry?.[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined
122+
const entry = registry?.[registryKey] as NormalizedRegistryEntry | undefined
123123
if (!entry)
124124
return true
125125
const [input, scriptOptions] = entry
@@ -143,7 +143,7 @@ export function applyAutoInject(
143143
if (isProxyDisabled(registryKey, registry, runtimeConfig))
144144
return
145145

146-
const entry = registry[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry
146+
const entry = registry[registryKey] as NormalizedRegistryEntry
147147
const input = entry[0]
148148

149149
const rtScripts = runtimeConfig.public?.scripts as Record<string, any> | undefined

packages/script/src/runtime/types.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,25 @@ export type RegistryScriptKey = Exclude<keyof ScriptRegistry, `${string}-npm`>
252252
type RegistryConfigInput<T> = [T] extends [true] ? Record<string, never> : T
253253

254254
export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | (RegistryConfigInput<T> & { trigger?: NuxtUseScriptOptionsSerializable['trigger'] | false, proxy?: boolean, bundle?: boolean, partytown?: boolean, privacy?: ProxyPrivacyInput })
255-
export type NuxtConfigScriptRegistry<T extends keyof ScriptRegistry = keyof ScriptRegistry> = Partial<{
256-
[key in T]: NuxtConfigScriptRegistryEntry<ScriptRegistry[key]>
257-
}> & Record<string & {}, NuxtConfigScriptRegistryEntry<any>>
255+
256+
// Internal mapped type: derives config entry types from ScriptRegistry.
257+
// Excludes the `${string}-npm` pattern since it's covered by the string index signature.
258+
type _NuxtConfigScriptRegistryEntries = {
259+
[K in keyof ScriptRegistry as K extends `${string}-npm` ? never : K]?: NuxtConfigScriptRegistryEntry<ScriptRegistry[K]>
260+
}
261+
262+
// Interface (not intersection) ensures IDE displays specific types for known keys.
263+
// Explicit properties inherited via `extends` always take priority over the index
264+
// signature, making this immune to catch-all type contamination.
265+
// Augmenting ScriptRegistry automatically flows through to this type.
266+
//
267+
// The index signature uses `any` to satisfy TypeScript's constraint that all
268+
// inherited properties must be subtypes of the index type. This is safe because
269+
// in an interface, explicit properties always take priority over the index
270+
// signature for property access.
271+
export interface NuxtConfigScriptRegistry extends _NuxtConfigScriptRegistryEntries {
272+
[key: string]: any
273+
}
258274

259275
export type UseFunctionType<T, U> = T extends {
260276
use: infer V

packages/script/src/templates.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,15 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
115115
for (const [k, c] of Object.entries(config.registry || {})) {
116116
if (c === false)
117117
continue
118-
const [, scriptOptions] = c as [Record<string, any>, any?]
118+
const entry = c as unknown as [Record<string, any>, any?]
119+
const [, scriptOptions] = entry
119120
if (!scriptOptions?.trigger)
120121
continue
121122
const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`)
122123
if (importDefinition) {
123124
resolvedRegistryKeys.push(k)
124125
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
125-
const [input] = c as [Record<string, any>, any?]
126+
const [input] = entry
126127
const opts = { ...scriptOptions }
127128
const triggerResolved = resolveTriggerForTemplate(opts.trigger)
128129
if (triggerResolved) {

test/types/types.test-d.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ModuleOptions } from '../../packages/script/src/module'
22
import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp'
33
import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics'
44
import type {
5+
NuxtConfigScriptRegistry,
56
NuxtUseScriptOptions,
67
RegistryScriptInput,
78
ScriptRegistry,
@@ -10,14 +11,72 @@ import type {
1011
import { describe, expectTypeOf, it } from 'vitest'
1112

1213
describe('module options registry', () => {
13-
it('registry entries are typed', () => {
14-
// Check specific registry keys have proper types (not any)
15-
// Using specific keys because the index signature `[key: \`${string}-npm\`]`
16-
// causes `keyof ScriptRegistry` to include template literals which resolve to any
17-
type Registry = NonNullable<ModuleOptions['registry']>
18-
expectTypeOf<Registry['googleAnalytics']>().not.toBeAny()
14+
type Registry = NonNullable<ModuleOptions['registry']>
15+
16+
it('all registry entries are typed, not any', () => {
17+
// Every built-in registry key must resolve to its specific type, not `any`.
18+
// NuxtConfigScriptRegistry is an interface (not an intersection), so explicit
19+
// properties inherited via `extends` always take priority over the index signature.
20+
expectTypeOf<Registry['bingUet']>().not.toBeAny()
21+
expectTypeOf<Registry['blueskyEmbed']>().not.toBeAny()
22+
expectTypeOf<Registry['carbonAds']>().not.toBeAny()
23+
expectTypeOf<Registry['crisp']>().not.toBeAny()
1924
expectTypeOf<Registry['clarity']>().not.toBeAny()
25+
expectTypeOf<Registry['cloudflareWebAnalytics']>().not.toBeAny()
26+
expectTypeOf<Registry['databuddyAnalytics']>().not.toBeAny()
27+
expectTypeOf<Registry['metaPixel']>().not.toBeAny()
28+
expectTypeOf<Registry['fathomAnalytics']>().not.toBeAny()
29+
expectTypeOf<Registry['instagramEmbed']>().not.toBeAny()
30+
expectTypeOf<Registry['plausibleAnalytics']>().not.toBeAny()
31+
expectTypeOf<Registry['googleAdsense']>().not.toBeAny()
32+
expectTypeOf<Registry['googleAnalytics']>().not.toBeAny()
33+
expectTypeOf<Registry['googleMaps']>().not.toBeAny()
34+
expectTypeOf<Registry['googleRecaptcha']>().not.toBeAny()
35+
expectTypeOf<Registry['googleSignIn']>().not.toBeAny()
36+
expectTypeOf<Registry['lemonSqueezy']>().not.toBeAny()
37+
expectTypeOf<Registry['googleTagManager']>().not.toBeAny()
38+
expectTypeOf<Registry['hotjar']>().not.toBeAny()
39+
expectTypeOf<Registry['intercom']>().not.toBeAny()
40+
expectTypeOf<Registry['paypal']>().not.toBeAny()
41+
expectTypeOf<Registry['posthog']>().not.toBeAny()
42+
expectTypeOf<Registry['matomoAnalytics']>().not.toBeAny()
43+
expectTypeOf<Registry['mixpanelAnalytics']>().not.toBeAny()
44+
expectTypeOf<Registry['rybbitAnalytics']>().not.toBeAny()
45+
expectTypeOf<Registry['redditPixel']>().not.toBeAny()
46+
expectTypeOf<Registry['segment']>().not.toBeAny()
2047
expectTypeOf<Registry['stripe']>().not.toBeAny()
48+
expectTypeOf<Registry['tiktokPixel']>().not.toBeAny()
49+
expectTypeOf<Registry['xEmbed']>().not.toBeAny()
50+
expectTypeOf<Registry['xPixel']>().not.toBeAny()
51+
expectTypeOf<Registry['snapchatPixel']>().not.toBeAny()
52+
expectTypeOf<Registry['youtubePlayer']>().not.toBeAny()
53+
expectTypeOf<Registry['vercelAnalytics']>().not.toBeAny()
54+
expectTypeOf<Registry['vimeoPlayer']>().not.toBeAny()
55+
expectTypeOf<Registry['umamiAnalytics']>().not.toBeAny()
56+
expectTypeOf<Registry['gravatar']>().not.toBeAny()
57+
expectTypeOf<Registry['npm']>().not.toBeAny()
58+
})
59+
60+
it('known keys resolve to their exact entry type, not the catch-all', () => {
61+
// Known registry keys must resolve to NuxtConfigScriptRegistryEntry<SpecificInput>,
62+
// not the index signature's `any` catch-all.
63+
// The interface approach guarantees this: inherited properties from `extends` always
64+
// take priority over the index signature.
65+
type GoogleMapsEntry = NuxtConfigScriptRegistry['googleMaps']
66+
type CatchAllEntry = NuxtConfigScriptRegistry[string]
67+
// Known key must NOT equal the catch-all
68+
expectTypeOf<GoogleMapsEntry>().not.toEqualTypeOf<CatchAllEntry>()
69+
70+
// Verify specific input properties survive (not collapsed to unknown)
71+
type ObjectForm<K extends keyof Registry> = Exclude<Registry[K], boolean | 'mock' | undefined>
72+
expectTypeOf<ObjectForm<'googleMaps'>['apiKey']>().not.toBeNever()
73+
expectTypeOf<ObjectForm<'googleAnalytics'>['id']>().not.toBeNever()
74+
expectTypeOf<ObjectForm<'clarity'>['id']>().not.toBeNever()
75+
})
76+
77+
it('registry allows unknown keys as catch-all', () => {
78+
// Unknown keys fall through to the index signature (any), so custom scripts work
79+
expectTypeOf<Registry['my-custom-script']>().toBeAny()
2180
})
2281
})
2382

0 commit comments

Comments
 (0)