diff --git a/docs/content/docs/1.guides/4.global.md b/docs/content/docs/1.guides/4.global.md index 07a350a1..f4daee1f 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 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. 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..a39aaee7 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 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, fn: string | symbol, args: any, exists: boolean }) => HookResult`{lang="ts"} diff --git a/packages/script/src/templates.ts b/packages/script/src/templates.ts index 50065b90..b4878133 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 + // 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 @@ -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) => { 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}`)) + } 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..e471bfaf 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,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('/') + 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"') + }) + + 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 3e493cf2..838641c8 100644 --- a/test/fixtures/issue-759/app.vue +++ b/test/fixtures/issue-759/app.vue @@ -7,5 +7,8 @@ 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 || '' }}
+
{{ $scripts?.legacy ? 'yes' : 'no' }}
diff --git a/test/fixtures/issue-759/nuxt.config.ts b/test/fixtures/issue-759/nuxt.config.ts index 8e487994..f8122f39 100644 --- a/test/fixtures/issue-759/nuxt.config.ts +++ b/test/fixtures/issue-759/nuxt.config.ts @@ -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', diff --git a/test/fixtures/issue-759/plugins/globals.ts b/test/fixtures/issue-759/plugins/globals.ts new file mode 100644 index 00000000..9a2b8609 --- /dev/null +++ b/test/fixtures/issue-759/plugins/globals.ts @@ -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 + }) + }, +}) diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index c06a3275..9328613e 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) => { 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 } } } } })" @@ -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({