Skip to content

Commit 85c19a4

Browse files
committed
feat!: redesign registry config — presence = available, trigger = load
BREAKING CHANGE: Registry config no longer auto-loads scripts globally. Adding a script to `scripts.registry` now only sets up infrastructure (proxy routes, types, bundling, composable auto-imports). Scripts only auto-load globally when `trigger` is explicitly set. - `true` removed: use `{}` (infra only) or `{ trigger: 'onNuxtReady' }` (global load) - `'proxy-only'` removed: infra-only is now the default behavior - `trigger` hoisted to top-level config alongside script input options - Tuple form `[input, scriptOptions]` still supported internally Migration: - `googleAnalytics: true` → `googleAnalytics: {}` or `googleAnalytics: { trigger: 'onNuxtReady' }` - `googleAnalytics: { id: 'G-xxx' }` → unchanged (but no longer auto-loads; add `trigger` to auto-load) - `googleAnalytics: 'proxy-only'` → `googleAnalytics: {}`
1 parent d8378ce commit 85c19a4

9 files changed

Lines changed: 122 additions & 64 deletions

File tree

playground/nuxt.config.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ export default defineNuxtConfig({
4646
scripts: {
4747
debug: true,
4848
registry: {
49-
googleSignIn: true,
50-
blueskyEmbed: true,
51-
xEmbed: true,
52-
instagramEmbed: true,
53-
googleMaps: true,
49+
googleSignIn: {},
50+
blueskyEmbed: {},
51+
xEmbed: {},
52+
instagramEmbed: {},
53+
googleMaps: {},
5454
},
5555
},
5656
})

src/module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ export interface ModuleOptions {
185185
*/
186186
firstParty?: boolean | FirstPartyOptions
187187
/**
188-
* The registry of supported third-party scripts. Loads the scripts in globally using the default script options.
188+
* The registry of supported third-party scripts. Presence enables infrastructure (proxy routes, types, bundling, composable auto-imports).
189+
* Scripts only auto-load globally when `trigger` is explicitly set in the config object.
189190
*/
190191
registry?: NuxtConfigScriptRegistry
191192
/**

src/normalize.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
/**
22
* Normalize all registry config entries in-place to [input, scriptOptions?] tuple form.
3-
* Eliminates the 4-shape polymorphism (true | 'mock' | object | [object, options])
4-
* so all downstream consumers handle a single shape.
53
*
6-
* - `true` → `[{}]`
4+
* User-facing config shapes:
5+
* - `false` → deleted
76
* - `'mock'` → `[{}, { trigger: 'manual', skipValidation: true }]`
8-
* - `{ id: '...' }` → `[{ id: '...' }]`
9-
* - `[{ id: '...' }, opts]` → unchanged
10-
* - falsy / empty array → deleted
7+
* - `{}` → `[{}]` (infrastructure only, no auto-load)
8+
* - `{ id: '...', trigger: 'onNuxtReady' }` → `[{ id: '...' }, { trigger: 'onNuxtReady' }]`
9+
* - `{ id: '...', trigger: 'onNuxtReady', scriptOptions: { warmupStrategy: 'preconnect' } }` → `[{ id: '...' }, { trigger: 'onNuxtReady', warmupStrategy: 'preconnect' }]`
10+
* - `[input, scriptOptions]` → unchanged (internal/backwards compat)
11+
*
12+
* Removed:
13+
* - `true` → build error with migration message
14+
* - `'proxy-only'` → build error with migration message
1115
*/
1216
export function normalizeRegistryConfig(registry: Record<string, any>): void {
1317
for (const key of Object.keys(registry)) {
@@ -17,9 +21,18 @@ export function normalizeRegistryConfig(registry: Record<string, any>): void {
1721
continue
1822
}
1923
if (entry === true) {
20-
registry[key] = [{}]
24+
throw new Error(
25+
`[nuxt-scripts] registry.${key}: boolean \`true\` is no longer supported. `
26+
+ `Use \`{}\` for infrastructure only (composable/component driven) or \`{ trigger: 'onNuxtReady' }\` for global auto-loading.`,
27+
)
28+
}
29+
if (entry === 'proxy-only') {
30+
throw new Error(
31+
`[nuxt-scripts] registry.${key}: \`'proxy-only'\` is no longer supported. `
32+
+ `Use \`{}\` instead (infrastructure only is now the default behavior).`,
33+
)
2134
}
22-
else if (entry === 'mock') {
35+
if (entry === 'mock') {
2336
registry[key] = [{}, { trigger: 'manual', skipValidation: true }]
2437
}
2538
else if (Array.isArray(entry)) {
@@ -31,7 +44,14 @@ export function normalizeRegistryConfig(registry: Record<string, any>): void {
3144
entry[0] = {}
3245
}
3346
else if (typeof entry === 'object') {
34-
registry[key] = [entry]
47+
const { trigger, scriptOptions, ...input } = entry
48+
const mergedScriptOptions = {
49+
...(trigger !== undefined ? { trigger } : {}),
50+
...scriptOptions,
51+
}
52+
registry[key] = Object.keys(mergedScriptOptions).length > 0
53+
? [input, mergedScriptOptions]
54+
: [input]
3555
}
3656
else {
3757
delete registry[key]

src/runtime/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export interface NuxtDevToolsScriptInstance {
167167
export interface ScriptRegistry {
168168
bingUet?: BingUetInput
169169
blueskyEmbed?: BlueskyEmbedInput
170-
carbonAds?: true
170+
carbonAds?: Record<string, never>
171171
crisp?: CrispInput
172172
clarity?: ClarityInput
173173
cloudflareWebAnalytics?: CloudflareWebAnalyticsInput
@@ -226,7 +226,7 @@ export type BuiltInRegistryScriptKey
226226
*/
227227
export type RegistryScriptKey = Exclude<keyof ScriptRegistry, `${string}-npm`>
228228

229-
export type NuxtConfigScriptRegistryEntry<T> = true | false | 'mock' | T | [T, NuxtUseScriptOptionsSerializable]
229+
export type NuxtConfigScriptRegistryEntry<T> = false | 'mock' | (T & { trigger?: NuxtUseScriptOptionsSerializable['trigger'], scriptOptions?: Omit<NuxtUseScriptOptionsSerializable, 'trigger'> }) | [T, NuxtUseScriptOptionsSerializable]
230230
export type NuxtConfigScriptRegistry<T extends keyof ScriptRegistry = keyof ScriptRegistry> = Partial<{
231231
[key in T]: NuxtConfigScriptRegistryEntry<ScriptRegistry[key]>
232232
}> & Record<string & {}, NuxtConfigScriptRegistryEntry<any>>

src/templates.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -117,36 +117,36 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
117117

118118
let needsServiceWorkerImport = false
119119

120-
// Registry entries are pre-normalized to [input, scriptOptions?] tuple form
120+
// Registry entries are pre-normalized to [input, scriptOptions?] tuple form.
121+
// Only generate a global composable call when scriptOptions.trigger is present;
122+
// entries without a trigger are infrastructure only (proxy routes, types, bundling).
121123
for (const [k, c] of Object.entries(config.registry || {})) {
122124
if (c === false)
123125
continue
126+
const [, scriptOptions] = c as [Record<string, any>, any?]
127+
if (!scriptOptions?.trigger)
128+
continue
124129
const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`)
125130
if (importDefinition) {
126131
resolvedRegistryKeys.push(k)
127132
imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`)
128-
const [input, scriptOptions] = c as [Record<string, any>, any?]
129-
if (scriptOptions) {
130-
const opts = { ...scriptOptions }
131-
const triggerResolved = resolveTriggerForTemplate(opts.trigger)
132-
if (triggerResolved) {
133-
opts.trigger = '__TRIGGER_PLACEHOLDER__' as any
134-
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
135-
needsIdleTimeoutImport = true
136-
if (triggerResolved.includes('useScriptTriggerInteraction'))
137-
needsInteractionImport = true
138-
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
139-
needsServiceWorkerImport = true
140-
}
141-
const args = { ...input, scriptOptions: opts }
142-
const argsJson = triggerResolved
143-
? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
144-
: JSON.stringify(args)
145-
inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`)
146-
}
147-
else {
148-
inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(input)})`)
133+
const [input] = c as [Record<string, any>, any?]
134+
const opts = { ...scriptOptions }
135+
const triggerResolved = resolveTriggerForTemplate(opts.trigger)
136+
if (triggerResolved) {
137+
opts.trigger = '__TRIGGER_PLACEHOLDER__' as any
138+
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
139+
needsIdleTimeoutImport = true
140+
if (triggerResolved.includes('useScriptTriggerInteraction'))
141+
needsInteractionImport = true
142+
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
143+
needsServiceWorkerImport = true
149144
}
145+
const args = { ...input, scriptOptions: opts }
146+
const argsJson = triggerResolved
147+
? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
148+
: JSON.stringify(args)
149+
inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`)
150150
}
151151
}
152152
for (const [k, c] of Object.entries(config.globals || {})) {

test/fixtures/basic/nuxt.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ export default defineNuxtConfig({
66
],
77
scripts: {
88
registry: {
9-
xEmbed: true,
10-
instagramEmbed: true,
11-
blueskyEmbed: true,
12-
gravatar: true,
9+
xEmbed: {},
10+
instagramEmbed: {},
11+
blueskyEmbed: {},
12+
gravatar: {},
1313
},
1414
},
1515
devtools: {

test/fixtures/first-party/nuxt.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export default defineNuxtConfig({
7373
posthog: [{ apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, manual],
7474
intercom: [{ app_id: 'akg5rmxb' }, manual],
7575
crisp: [{ id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, manual],
76-
vercelAnalytics: [true, manual],
76+
vercelAnalytics: [{}, manual],
7777
},
7878
},
7979
})

test/unit/auto-inject.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,56 +85,56 @@ describe('autoInject via proxy configs', () => {
8585
})
8686
})
8787

88-
describe('boolean entries', () => {
89-
it('injects into runtimeConfig for posthog: true', () => {
90-
const registry: any = { posthog: true }
88+
describe('empty object entries (env var driven)', () => {
89+
it('injects into runtimeConfig for posthog: {}', () => {
90+
const registry: any = { posthog: {} }
9191
const rt = makeRuntimeConfig({ posthog: { apiKey: '' } })
9292

9393
autoInjectAll(registry, rt, '/_proxy')
9494

95-
// After normalization, true becomes [{}] — both input and runtimeConfig get the value
95+
// After normalization, {} becomes [{}] — both input and runtimeConfig get the value
9696
expect(registry.posthog[0].apiHost).toBe('/_proxy/ph')
9797
expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/ph')
9898
})
9999

100-
it('uses EU prefix for posthog: true when runtime region is eu', () => {
101-
const registry: any = { posthog: true }
100+
it('uses EU prefix for posthog: {} when runtime region is eu', () => {
101+
const registry: any = { posthog: {} }
102102
const rt = makeRuntimeConfig({ posthog: { apiKey: '', region: 'eu' } })
103103

104104
autoInjectAll(registry, rt, '/_proxy')
105105

106106
expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/ph-eu')
107107
})
108108

109-
it('injects into runtimeConfig for plausibleAnalytics: true', () => {
110-
const registry: any = { plausibleAnalytics: true }
109+
it('injects into runtimeConfig for plausibleAnalytics: {}', () => {
110+
const registry: any = { plausibleAnalytics: {} }
111111
const rt = makeRuntimeConfig({ plausibleAnalytics: { domain: '' } })
112112

113113
autoInjectAll(registry, rt, '/_proxy')
114114

115115
expect(rt.public.scripts.plausibleAnalytics.endpoint).toBe('/_proxy/plausible/api/event')
116116
})
117117

118-
it('injects into runtimeConfig for umamiAnalytics: true', () => {
119-
const registry: any = { umamiAnalytics: true }
118+
it('injects into runtimeConfig for umamiAnalytics: {}', () => {
119+
const registry: any = { umamiAnalytics: {} }
120120
const rt = makeRuntimeConfig({ umamiAnalytics: { websiteId: '' } })
121121

122122
autoInjectAll(registry, rt, '/_proxy')
123123

124124
expect(rt.public.scripts.umamiAnalytics.hostUrl).toBe('/_proxy/umami')
125125
})
126126

127-
it('injects into runtimeConfig for rybbitAnalytics: true', () => {
128-
const registry: any = { rybbitAnalytics: true }
127+
it('injects into runtimeConfig for rybbitAnalytics: {}', () => {
128+
const registry: any = { rybbitAnalytics: {} }
129129
const rt = makeRuntimeConfig({ rybbitAnalytics: { siteId: '' } })
130130

131131
autoInjectAll(registry, rt, '/_proxy')
132132

133133
expect(rt.public.scripts.rybbitAnalytics.analyticsHost).toBe('/_proxy/rybbit/api')
134134
})
135135

136-
it('injects into runtimeConfig for databuddyAnalytics: true', () => {
137-
const registry: any = { databuddyAnalytics: true }
136+
it('injects into runtimeConfig for databuddyAnalytics: {}', () => {
137+
const registry: any = { databuddyAnalytics: {} }
138138
const rt = makeRuntimeConfig({ databuddyAnalytics: { clientId: '' } })
139139

140140
autoInjectAll(registry, rt, '/_proxy')
@@ -185,7 +185,7 @@ describe('autoInject via proxy configs', () => {
185185

186186
describe('custom proxyPrefix', () => {
187187
it('uses custom prefix in computed values', () => {
188-
const registry: any = { posthog: true }
188+
const registry: any = { posthog: {} }
189189
const rt = makeRuntimeConfig({ posthog: { apiKey: '' } })
190190

191191
autoInjectAll(registry, rt, '/_analytics')

test/unit/templates.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('template plugin file', () => {
110110
`)
111111
})
112112
// registry
113-
it('registry object', async () => {
113+
it('registry object without trigger (infrastructure only, no composable call)', async () => {
114114
const res = templatePluginNormalized({
115115
globals: {},
116116
registry: {
@@ -125,9 +125,27 @@ describe('template plugin file', () => {
125125
},
126126
},
127127
])
128-
expect(res).toContain('useScriptStripe({"id":"test"})')
128+
expect(res).not.toContain('useScriptStripe')
129129
})
130-
it('registry array', async () => {
130+
it('registry object with trigger (auto-loads globally)', async () => {
131+
const res = templatePluginNormalized({
132+
globals: {},
133+
registry: {
134+
stripe: {
135+
id: 'test',
136+
trigger: 'onNuxtReady',
137+
},
138+
},
139+
}, [
140+
{
141+
import: {
142+
name: 'useScriptStripe',
143+
},
144+
},
145+
])
146+
expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})')
147+
})
148+
it('registry array with trigger', async () => {
131149
const res = templatePluginNormalized({
132150
globals: {},
133151
registry: {
@@ -150,7 +168,7 @@ describe('template plugin file', () => {
150168
expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})')
151169
})
152170

153-
it('registry with partytown option', async () => {
171+
it('registry with partytown but no trigger (no composable call)', async () => {
154172
const res = templatePluginNormalized({
155173
globals: {},
156174
registry: {
@@ -166,7 +184,26 @@ describe('template plugin file', () => {
166184
},
167185
},
168186
])
169-
expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true}})')
187+
expect(res).not.toContain('useScriptGoogleAnalytics')
188+
})
189+
190+
it('registry with partytown and trigger', async () => {
191+
const res = templatePluginNormalized({
192+
globals: {},
193+
registry: {
194+
googleAnalytics: [
195+
{ id: 'G-XXXXX' },
196+
{ partytown: true, trigger: 'onNuxtReady' },
197+
],
198+
},
199+
}, [
200+
{
201+
import: {
202+
name: 'useScriptGoogleAnalytics',
203+
},
204+
},
205+
])
206+
expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true,"trigger":"onNuxtReady"}})')
170207
})
171208

172209
// Test idleTimeout trigger in globals

0 commit comments

Comments
 (0)