From 7c4a1ce3accb1b9c9fe6ab2c8e2517f21cc4c30a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 2 Jun 2026 12:59:14 +1000 Subject: [PATCH 1/3] feat(globals): runtime disable + scripts:globals hook Globals are resolved at build time, so a single build couldn't drop or recompute an entry per instance. Two runtime escapes, both keyed off the existing scriptsGlobals override: - Registration now routes through a guard that skips an entry whose merged input has enabled: false or an empty/null src, so an unused integration can be dropped per deployment via NUXT_PUBLIC_SCRIPTS_GLOBALS__SRC=. - The generated plugin builds a mutable map of resolved inputs and fires a scripts:globals app hook before registering, letting userland rewrite src, delete keys, or add entries from runtime config. Static globals keep their $scripts types and asset bundling, which a globals factory would lose. Related to #759 --- packages/script/src/templates.ts | 31 ++++++- .../issue-759-globals-env-override.test.ts | 15 ++++ test/fixtures/issue-759/app.vue | 2 + test/fixtures/issue-759/nuxt.config.ts | 7 ++ test/fixtures/issue-759/plugins/globals.ts | 10 +++ test/unit/templates.test.ts | 85 +++++++++++++++++-- 6 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/issue-759/plugins/globals.ts diff --git a/packages/script/src/templates.ts b/packages/script/src/templates.ts index 50065b90..400b8ec9 100644 --- a/packages/script/src/templates.ts +++ b/packages/script/src/templates.ts @@ -26,6 +26,9 @@ declare module '#app' { } interface RuntimeNuxtHooks { 'scripts:updated': (ctx: { scripts: Record }) => void | Promise + 'scripts:globals': (globals: ${globalsKeys.length + ? `{ ${globalsKeys.map(k => `${JSON.stringify(k)}: Record`).join('; ')} }` + : 'Record>'}) => void | Promise } } ${globalsKeys.length @@ -113,6 +116,10 @@ export function templatePlugin(config: Partial, 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 @@ -211,7 +218,13 @@ export function templatePlugin(config: Partial, 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 = [] @@ -226,8 +239,20 @@ export function templatePlugin(config: Partial, 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) => { 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}`)) + } setupBody.push(...inits.map(i => ` ${i}`)) setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`) return [ @@ -240,7 +265,7 @@ export function templatePlugin(config: Partial, registry: Require ` name: "scripts:init",`, ` env: { islands: false },`, ` parallel: true,`, - ` setup() {`, + ` ${hasGlobals ? 'async setup(nuxtApp) {' : 'setup() {'}`, ...setupBody, ` }`, `})`, diff --git a/test/e2e/issue-759-globals-env-override.test.ts b/test/e2e/issue-759-globals-env-override.test.ts index 0016228d..d78fa417 100644 --- a/test/e2e/issue-759-globals-env-override.test.ts +++ b/test/e2e/issue-759-globals-env-override.test.ts @@ -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'), @@ -22,4 +24,17 @@ 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('/') + expect(html).toContain('
no
') + }) + + it('the scripts:globals hook rewrites a global src at runtime', async () => { + const html = await $fetch('/') + // 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"') + }) }) diff --git a/test/fixtures/issue-759/app.vue b/test/fixtures/issue-759/app.vue index 3e493cf2..6b94f9c3 100644 --- a/test/fixtures/issue-759/app.vue +++ b/test/fixtures/issue-759/app.vue @@ -7,5 +7,7 @@ const rc = useRuntimeConfig()
{{ JSON.stringify(rc.public.scriptsGlobals) }}
{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}
+
{{ $scripts?.awin ? 'yes' : 'no' }}
+
{{ $scripts?.scrads?.$script?.src || $scripts?.scrads?.options?.src || '' }}
diff --git a/test/fixtures/issue-759/nuxt.config.ts b/test/fixtures/issue-759/nuxt.config.ts index 8e487994..09873d9b 100644 --- a/test/fixtures/issue-759/nuxt.config.ts +++ b/test/fixtures/issue-759/nuxt.config.ts @@ -11,6 +11,13 @@ 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' }, + ], }, }, compatibilityDate: '2024-07-05', diff --git a/test/fixtures/issue-759/plugins/globals.ts b/test/fixtures/issue-759/plugins/globals.ts new file mode 100644 index 00000000..3c5d9f5c --- /dev/null +++ b/test/fixtures/issue-759/plugins/globals.ts @@ -0,0 +1,10 @@ +// `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: Record>) => { + globals.scrads.src = 'https://scrads.example/from-hook.js' + }) + }, +}) diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index c06a3275..c5636b20 100644 --- a/test/unit/templates.test.ts +++ b/test/unit/templates.test.ts @@ -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({ @@ -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({ @@ -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({ @@ -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) => { 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 } } } } })" @@ -289,6 +299,67 @@ 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) => { 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) => { + 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() + // `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({ From 26a4c9458cf9a3b8c3f5a2c45a6aaf5525ff7d0d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 2 Jun 2026 12:59:14 +1000 Subject: [PATCH 2/3] docs(globals): document runtime disable and scripts:globals hook --- docs/content/docs/1.guides/4.global.md | 17 ++++++++++++++ docs/content/docs/3.api/6.nuxt-app-hooks.md | 26 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/docs/content/docs/1.guides/4.global.md b/docs/content/docs/1.guides/4.global.md index 07a350a1..c2316788 100644 --- a/docs/content/docs/1.guides/4.global.md +++ b/docs/content/docs/1.guides/4.global.md @@ -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 add/remove entries with logic), tap the [`scripts:globals`](/docs/api/nuxt-app-hooks#scriptsglobals) runtime hook. It hands you a mutable map of the resolved global inputs right before they register, so you can rewrite, `delete`, or add entries per instance without rebuilding, while statically declared globals 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. diff --git a/docs/content/docs/3.api/6.nuxt-app-hooks.md b/docs/content/docs/3.api/6.nuxt-app-hooks.md index d56d35a0..2cbe8492 100644 --- a/docs/content/docs/3.api/6.nuxt-app-hooks.md +++ b/docs/content/docs/3.api/6.nuxt-app-hooks.md @@ -21,6 +21,32 @@ export default defineNuxtPlugin({ }) ``` +## `scripts:globals`{lang="ts"} + +- Type: `(globals: Record>) => void | Promise`{lang="ts"} + +Fired inside the generated `scripts:init` plugin, right before each `scripts.globals` entry is registered. `globals` is a mutable map 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, `delete` an entry so it never loads, or add a brand new entry, 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 the rest 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. + +Register the listener from an `enforce: 'pre'` plugin so it runs before `scripts:init`, otherwise the listener is added 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, fn: string | symbol, args: any, exists: boolean }) => HookResult`{lang="ts"} From 28c5c12985837e7ac4ad90ca51c7b5162c1dea16 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 2 Jun 2026 13:17:19 +1000 Subject: [PATCH 3/3] fix(globals): guard scripts:globals hook deletes, narrow hook scope Addresses CodeRabbit review on #808: - __registerGlobal guards against a missing input, so a listener that does `delete globals[key]` no longer throws 'Cannot destructure ... undefined' and abort plugin setup. Covered by a unit case and an e2e delete case. - Hook type is an index signature (Record>) so `delete globals[key]` and `globals[key].src = ...` both typecheck (no TS2790 / possibly-undefined). - Docs no longer claim the hook can add brand-new globals; registration only iterates declared keys, so net-new scripts go through useScript() in a plugin. --- docs/content/docs/1.guides/4.global.md | 2 +- docs/content/docs/3.api/6.nuxt-app-hooks.md | 6 +++--- packages/script/src/templates.ts | 8 ++++---- test/e2e/issue-759-globals-env-override.test.ts | 8 ++++++++ test/fixtures/issue-759/app.vue | 1 + test/fixtures/issue-759/nuxt.config.ts | 2 ++ test/fixtures/issue-759/plugins/globals.ts | 4 +++- test/unit/templates.test.ts | 8 ++++++-- 8 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/1.guides/4.global.md b/docs/content/docs/1.guides/4.global.md index c2316788..f4daee1f 100644 --- a/docs/content/docs/1.guides/4.global.md +++ b/docs/content/docs/1.guides/4.global.md @@ -114,7 +114,7 @@ A disabled global resolves to `undefined` on `$scripts`, so guard access (`$scri #### Computing globals at runtime -When env-var overrides aren't enough (you need to compute `src` from runtime config, or conditionally add/remove entries with logic), tap the [`scripts:globals`](/docs/api/nuxt-app-hooks#scriptsglobals) runtime hook. It hands you a mutable map of the resolved global inputs right before they register, so you can rewrite, `delete`, or add entries per instance without rebuilding, while statically declared globals keep their `$scripts` types and asset bundling. +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:** diff --git a/docs/content/docs/3.api/6.nuxt-app-hooks.md b/docs/content/docs/3.api/6.nuxt-app-hooks.md index 2cbe8492..a39aaee7 100644 --- a/docs/content/docs/3.api/6.nuxt-app-hooks.md +++ b/docs/content/docs/3.api/6.nuxt-app-hooks.md @@ -25,11 +25,11 @@ export default defineNuxtPlugin({ - Type: `(globals: Record>) => void | Promise`{lang="ts"} -Fired inside the generated `scripts:init` plugin, right before each `scripts.globals` entry is registered. `globals` is a mutable map 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, `delete` an entry so it never loads, or add a brand new entry, all per instance without a rebuild. +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 the rest 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. +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 the listener is added too late to fire. +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({ diff --git a/packages/script/src/templates.ts b/packages/script/src/templates.ts index 400b8ec9..b4878133 100644 --- a/packages/script/src/templates.ts +++ b/packages/script/src/templates.ts @@ -26,9 +26,9 @@ declare module '#app' { } interface RuntimeNuxtHooks { 'scripts:updated': (ctx: { scripts: Record }) => void | Promise - 'scripts:globals': (globals: ${globalsKeys.length - ? `{ ${globalsKeys.map(k => `${JSON.stringify(k)}: Record`).join('; ')} }` - : 'Record>'}) => void | Promise + // 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>) => void | Promise } } ${globalsKeys.length @@ -243,7 +243,7 @@ export function templatePlugin(config: Partial, registry: Require 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) => { const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) }`) + 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},`)) diff --git a/test/e2e/issue-759-globals-env-override.test.ts b/test/e2e/issue-759-globals-env-override.test.ts index d78fa417..e471bfaf 100644 --- a/test/e2e/issue-759-globals-env-override.test.ts +++ b/test/e2e/issue-759-globals-env-override.test.ts @@ -37,4 +37,12 @@ describe('issue-759 globals env override', () => { // ...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('/') + // Page renders (plugin setup didn't throw on the deleted entry)... + expect(html).toContain('
no
') + // ...and the deleted global is never registered/preloaded. + expect(html).not.toContain('href="https://legacy.example/baked.js"') + }) }) diff --git a/test/fixtures/issue-759/app.vue b/test/fixtures/issue-759/app.vue index 6b94f9c3..838641c8 100644 --- a/test/fixtures/issue-759/app.vue +++ b/test/fixtures/issue-759/app.vue @@ -9,5 +9,6 @@ const rc = useRuntimeConfig()
{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}
{{ $scripts?.awin ? 'yes' : 'no' }}
{{ $scripts?.scrads?.$script?.src || $scripts?.scrads?.options?.src || '' }}
+
{{ $scripts?.legacy ? 'yes' : 'no' }}
diff --git a/test/fixtures/issue-759/nuxt.config.ts b/test/fixtures/issue-759/nuxt.config.ts index 09873d9b..f8122f39 100644 --- a/test/fixtures/issue-759/nuxt.config.ts +++ b/test/fixtures/issue-759/nuxt.config.ts @@ -18,6 +18,8 @@ export default defineNuxtConfig({ { 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', diff --git a/test/fixtures/issue-759/plugins/globals.ts b/test/fixtures/issue-759/plugins/globals.ts index 3c5d9f5c..9a2b8609 100644 --- a/test/fixtures/issue-759/plugins/globals.ts +++ b/test/fixtures/issue-759/plugins/globals.ts @@ -3,8 +3,10 @@ export default defineNuxtPlugin({ enforce: 'pre', setup(nuxtApp) { - nuxtApp.hooks.hook('scripts:globals', (globals: Record>) => { + 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 }) }, }) diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index c5636b20..9328613e 100644 --- a/test/unit/templates.test.ts +++ b/test/unit/templates.test.ts @@ -105,7 +105,7 @@ describe('template plugin file', () => { parallel: true, async setup(nuxtApp) { const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {} - const __registerGlobal = (input, options) => { const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) } + 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"] || {}), @@ -310,7 +310,7 @@ describe('template plugin file', () => { }, }, []) // Helper is emitted once and strips `enabled` so it never leaks as a script attribute. - expect(res).toContain('const __registerGlobal = (input, options) => { const { enabled, ...rest } = input; return (enabled === false || rest.src === \'\' || rest.src === null) ? undefined : useScript(rest, options) }') + 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(') @@ -346,6 +346,8 @@ describe('template plugin file', () => { 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) } @@ -356,6 +358,8 @@ describe('template plugin file', () => { 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) })