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
17 changes: 17 additions & 0 deletions docs/content/docs/1.guides/4.global.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC=https://widgets.trustedshops.com/X

Any input field (e.g. `src`, `integrity`, `crossorigin`, or your own `data-*` attributes) can be overridden this way. The env value replaces the build-time default at runtime via `runtimeConfig.public.scriptsGlobals`.

#### Disabling a global per deployment

For multi-tenant single-build setups, an instance can skip a global it doesn't use without rebuilding. Set its `enabled` to `false`, or override `src` to an empty value, and the script is never registered for that deployment:

```bash [.env per deployment]
# Drop this integration for this instance only:
NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_SRC=
# or, if you keep an `enabled` field on the build-time default:
NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_ENABLED=false
```

A disabled global resolves to `undefined` on `$scripts`, so guard access (`$scripts.awin?.`) if a script may be turned off per instance.

#### Computing globals at runtime

When env-var overrides aren't enough (you need to compute `src` from runtime config, or conditionally remove entries with logic), tap the [`scripts:globals`](/docs/api/nuxt-app-hooks#scriptsglobals) runtime hook. It hands you a mutable map of your declared globals' resolved inputs right before they register, so you can rewrite or `delete` entries per instance without rebuilding, while they keep their `$scripts` types and asset bundling.

**Not overridable at runtime:**

- `scriptOptions` (the second tuple slot, e.g. `trigger`, `mode`) and object-form triggers stay baked in at build.
Expand Down
26 changes: 26 additions & 0 deletions docs/content/docs/3.api/6.nuxt-app-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ export default defineNuxtPlugin({
})
```

## `scripts:globals`{lang="ts"}

- Type: `(globals: Record<string, Record<string, any>>) => void | Promise<void>`{lang="ts"}

Fired inside the generated `scripts:init` plugin, right before it registers each `scripts.globals` entry. `globals` is a mutable map of your statically declared globals, keyed by each global's key, with values already merged (build-time default first, then any `NUXT_PUBLIC_SCRIPTS_GLOBALS_*` env override). Mutate it to rewrite a `src`/attributes, or `delete` an entry so it never loads, all per instance without a rebuild.

This is the runtime equivalent of a globals factory: it keeps statically declared globals typed on `$scripts` and asset-bundled, while letting you compute their inputs at server/client startup. Deleting an entry (or setting `enabled: false` / an empty `src`) skips its registration; that key then resolves to `undefined` on `$scripts`, so guard access. The hook operates on the declared set only; to load a script that isn't declared in `scripts.globals`, call [`useScript()`{lang="ts"}](/docs/api/use-script) in your own plugin.

Register the listener from an `enforce: 'pre'` plugin so it runs before `scripts:init`, otherwise it arrives too late to fire.

```ts [plugins/nuxt-scripts-globals.ts]
export default defineNuxtPlugin({
enforce: 'pre',
setup(nuxtApp) {
const { tenant } = useRuntimeConfig().public
nuxtApp.hooks.hook('scripts:globals', (globals) => {
// Drop an integration this tenant doesn't use:
if (!tenant.awinEnabled)
delete globals.awin
// Compute the src from runtime config:
globals.trustedShops.src = `https://widgets.trustedshops.com/${tenant.trustedShopsId}.js`
})
}
})
```

## `script:instance-fn`{lang="ts"} (Unhead Hook)

- Type: `(ctx: { script: ScriptInstance<any>, fn: string | symbol, args: any, exists: boolean }) => HookResult`{lang="ts"}
Expand Down
31 changes: 28 additions & 3 deletions packages/script/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ declare module '#app' {
}
interface RuntimeNuxtHooks {
'scripts:updated': (ctx: { scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance> }) => void | Promise<void>
// Index signature (not a literal-key Record) so listeners can both \`delete globals[key]\`
// and mutate \`globals[key].src\` without TS2790 / possibly-undefined errors.
'scripts:globals': (globals: Record<string, Record<string, any>>) => void | Promise<void>
}
}
${globalsKeys.length
Expand Down Expand Up @@ -113,6 +116,10 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
}
const imports = []
const inits = []
// Globals are split out so the resolved inputs can be collected into a mutable map,
// passed through the `scripts:globals` runtime hook, then registered.
const globalMapEntries: string[] = []
const globalInits: string[] = []
const resolvedRegistryKeys: string[] = []
let needsIdleTimeoutImport = false
let needsInteractionImport = false
Expand Down Expand Up @@ -211,7 +218,13 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
}
const useFn = `use: () => ({ ${k}: window.${k} })`
const optionsArg = optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }`
inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`)
// Resolved input goes into the mutable `__globals` map keyed by its global key, so the
// `scripts:globals` hook can rewrite/delete/add entries before registration.
globalMapEntries.push(`${JSON.stringify(k)}: ${inputExpr}`)
// Registration is guarded so a runtime override (env or hook) can disable an entry
// per-instance (multi-tenant single-build): set `enabled: false` or an empty/null `src`
// and the script is never registered. See https://github.com/nuxt/scripts/issues/759.
globalInits.push(`const ${k} = __registerGlobal(__globals[${JSON.stringify(k)}], ${optionsArg})`)
}
// Add conditional imports for trigger composables
const triggerImports = []
Expand All @@ -226,8 +239,20 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
}

const setupBody: string[] = []
if (hasGlobals)
if (hasGlobals) {
setupBody.push(` const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}`)
// A global is skipped when the merged input disables it: `enabled === false`, or an
// empty/null `src` (the env-overridable way to drop an unused integration per instance).
setupBody.push(` const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) }`)
// Resolved inputs (build-time default <- env override) collected into a mutable map.
setupBody.push(` const __globals = {`)
setupBody.push(...globalMapEntries.map(e => ` ${e},`))
setupBody.push(` }`)
// Runtime hook: userland may rewrite `src`/attrs, delete keys, or add new entries before
// registration. Register the listener from an `enforce: 'pre'` plugin so it runs first.
setupBody.push(` await nuxtApp.hooks.callHook('scripts:globals', __globals)`)
setupBody.push(...globalInits.map(i => ` ${i}`))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setupBody.push(...inits.map(i => ` ${i}`))
setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return [
Expand All @@ -240,7 +265,7 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
` name: "scripts:init",`,
` env: { islands: false },`,
` parallel: true,`,
` setup() {`,
` ${hasGlobals ? 'async setup(nuxtApp) {' : 'setup() {'}`,
...setupBody,
` }`,
`})`,
Expand Down
23 changes: 23 additions & 0 deletions test/e2e/issue-759-globals-env-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const { resolve } = createResolver(import.meta.url)
// This proves the single-build / multi-deploy contract for issue #759:
// the same build produces different rendered src values depending on env.
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC = 'https://widgets.trustedshops.com/from-env.js'
// Empty src disables the global for this instance (multi-tenant single build).
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_SRC = ''

await setup({
rootDir: resolve('../fixtures/issue-759'),
Expand All @@ -22,4 +24,25 @@ describe('issue-759 globals env override', () => {
expect(html).toContain('https://widgets.trustedshops.com/from-env.js')
expect(html).not.toContain('build-default.js')
})

it('a global with an empty src override is not registered for this instance', async () => {
const html = await $fetch<string>('/')
expect(html).toContain('<div id="awin-registered">no</div>')
})

it('the scripts:globals hook rewrites a global src at runtime', async () => {
const html = await $fetch<string>('/')
// The hook-mutated src is what actually gets registered/preloaded...
expect(html).toContain('href="https://scrads.example/from-hook.js"')
// ...while the un-mutated build-time src is never used for registration.
expect(html).not.toContain('href="https://scrads.example/baked.js"')
})

it('the scripts:globals hook can delete an entry without crashing setup', async () => {
const html = await $fetch<string>('/')
// Page renders (plugin setup didn't throw on the deleted entry)...
expect(html).toContain('<div id="legacy-registered">no</div>')
// ...and the deleted global is never registered/preloaded.
expect(html).not.toContain('href="https://legacy.example/baked.js"')
})
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-759/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ const rc = useRuntimeConfig()
<div>
<div id="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
<div id="script-src">{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}</div>
<div id="awin-registered">{{ $scripts?.awin ? 'yes' : 'no' }}</div>
<div id="scrads-src">{{ $scripts?.scrads?.$script?.src || $scripts?.scrads?.options?.src || '' }}</div>
<div id="legacy-registered">{{ $scripts?.legacy ? 'yes' : 'no' }}</div>
</div>
</template>
9 changes: 9 additions & 0 deletions test/fixtures/issue-759/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export default defineNuxtConfig({
{ src: 'https://widgets.trustedshops.com/build-default.js' },
{ trigger: 'onNuxtReady' },
],
// Disabled per-instance via an empty `src` env override (no rebuild).
awin: { src: 'https://www.dwin1.com/build-default.js' },
// `src` rewritten at runtime by the scripts:globals hook (see plugins/globals.ts).
scrads: [
{ src: 'https://scrads.example/baked.js' },
{ trigger: 'onNuxtReady' },
],
// `delete`d at runtime by the scripts:globals hook; must not crash plugin setup.
legacy: { src: 'https://legacy.example/baked.js' },
},
},
compatibilityDate: '2024-07-05',
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/issue-759/plugins/globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// `enforce: 'pre'` so the listener is registered before the module's scripts:init plugin
// runs and fires the hook. Proves runtime mutation of globals per instance (issue #759).
export default defineNuxtPlugin({
enforce: 'pre',
setup(nuxtApp) {
nuxtApp.hooks.hook('scripts:globals', (globals) => {
globals.scrads.src = 'https://scrads.example/from-hook.js'
// Removing an entry must skip its registration without crashing setup.
delete globals.legacy
})
},
})
89 changes: 82 additions & 7 deletions test/unit/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ describe('template plugin file', () => {
stripe: 'https://js.stripe.com/v3/',
},
}, [])
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })')
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe"] || {})')
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { use: () => ({ stripe: window.stripe }) })')
})
it('object global', async () => {
const res = templatePlugin({
Expand All @@ -50,7 +51,8 @@ describe('template plugin file', () => {
},
},
}, [])
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })')
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {})')
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { use: () => ({ stripe: window.stripe }) })')
})
it('array global', async () => {
const res = templatePlugin({
Expand All @@ -70,7 +72,8 @@ describe('template plugin file', () => {
],
},
}, [])
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })')
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {})')
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })')
})
it('mixing global', async () => {
const res = templatePlugin({
Expand Down Expand Up @@ -100,11 +103,18 @@ describe('template plugin file', () => {
name: "scripts:init",
env: { islands: false },
parallel: true,
setup() {
async setup(nuxtApp) {
const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}
const stripe1 = useScript(Object.assign({ key: "stripe1" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe1"] || {}), { use: () => ({ stripe1: window.stripe1 }) })
const stripe2 = useScript(Object.assign({ key: "stripe2" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe2"] || {}), { use: () => ({ stripe2: window.stripe2 }) })
const stripe3 = useScript(Object.assign({ key: "stripe3" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe3"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) })
const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) }
const __globals = {
"stripe1": Object.assign({ key: "stripe1" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe1"] || {}),
"stripe2": Object.assign({ key: "stripe2" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe2"] || {}),
"stripe3": Object.assign({ key: "stripe3" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe3"] || {}),
}
await nuxtApp.hooks.callHook('scripts:globals', __globals)
const stripe1 = __registerGlobal(__globals["stripe1"], { use: () => ({ stripe1: window.stripe1 }) })
const stripe2 = __registerGlobal(__globals["stripe2"], { use: () => ({ stripe2: window.stripe2 }) })
const stripe3 = __registerGlobal(__globals["stripe3"], { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) })
return { provide: { scripts: { stripe1, stripe2, stripe3 } } }
}
})"
Expand Down Expand Up @@ -289,6 +299,71 @@ describe('template plugin file', () => {
expect(res).toContain('Object.assign({ key: "trustedShops" }, {"src":"https://widgets.trustedshops.com/build-time.js"}, __scriptsGlobals["trustedShops"] || {})')
})

// A runtime override can drop an unused integration per instance (multi-tenant single
// build) by disabling it: `enabled: false` or an empty/null `src`. The generated plugin
// routes every global through __registerGlobal which skips registration when disabled.
// See https://github.com/nuxt/scripts/issues/759.
it('global registration is guarded so a runtime override can disable it', async () => {
const res = templatePlugin({
globals: {
awin: { src: 'https://www.dwin1.com/build-time.js' },
},
}, [])
// Helper is emitted once and strips `enabled` so it never leaks as a script attribute.
expect(res).toContain('const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === \'\' || rest.src === null) ? undefined : useScript(rest, options) }')
// Each global goes through the guard rather than calling useScript directly.
expect(res).toContain('const awin = __registerGlobal(__globals["awin"]')
expect(res).not.toContain('const awin = useScript(')
})

// Resolved inputs are collected into a mutable map and passed through a runtime hook
// before registration, so userland can rewrite/delete/add entries per instance.
// See https://github.com/nuxt/scripts/issues/759.
it('globals are passed through the scripts:globals runtime hook before registration', async () => {
const res = templatePlugin({
globals: {
awin: { src: 'https://www.dwin1.com/build-time.js' },
},
}, [])
// setup is async and receives nuxtApp so the hook can be awaited.
expect(res).toContain('async setup(nuxtApp) {')
// Resolved input lives in the mutable map keyed by the global key.
expect(res).toContain('"awin": Object.assign({ key: "awin" }')
// Hook fires before any registration, with the mutable map.
expect(res).toContain('await nuxtApp.hooks.callHook(\'scripts:globals\', __globals)')
const hookIdx = res.indexOf('callHook(\'scripts:globals\'')
const registerIdx = res.indexOf('const awin = __registerGlobal')
expect(hookIdx).toBeGreaterThan(-1)
expect(registerIdx).toBeGreaterThan(hookIdx)
})

// The runtime guard itself: enabled:false and empty/null src skip; undefined src (non-src
// globals) and a normal src register.
it('__registerGlobal skips disabled entries and registers the rest', () => {
const calls: any[] = []
const useScript = (input: any) => {
calls.push(input)
return input
}
const __registerGlobal = (input: any, options: any) => {
if (!input)
return undefined
const { enabled, ...rest } = input
return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options)
}

expect(__registerGlobal({ key: 'a', src: 'https://e/x.js' }, {})).toBeDefined()
expect(__registerGlobal({ key: 'b', src: 'https://e/x.js', enabled: false }, {})).toBeUndefined()
expect(__registerGlobal({ key: 'c', src: '' }, {})).toBeUndefined()
expect(__registerGlobal({ key: 'd', src: null }, {})).toBeUndefined()
// No src field (inline/non-src global) still registers.
expect(__registerGlobal({ key: 'e' }, {})).toBeDefined()
// A hook that deleted the entry leaves `undefined`; must not throw on destructure.
expect(__registerGlobal(undefined, {})).toBeUndefined()
// `enabled` is stripped, never forwarded to useScript as an attribute.
expect(calls.every(c => !('enabled' in c))).toBe(true)
})

// Test serviceWorker trigger in globals
it('global with serviceWorker trigger', async () => {
const res = templatePlugin({
Expand Down
Loading