From 51070ac7e5f1d2c297cd58925a5c9c577b42dc86 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 13 Oct 2026 00:28:15 +1100 Subject: [PATCH] fix(scripts): preserve consent when reusing script consumers --- .../docs/1.guides/1.registry-scripts.md | 6 +++ packages/script/src/module.ts | 7 ++- .../src/runtime/composables/useScript.ts | 4 ++ packages/script/src/runtime/utils.ts | 16 +++++-- test/e2e/basic.test.ts | 37 ++++++++++++++ test/fixtures/basic/nuxt.config.ts | 1 + test/fixtures/basic/pages/consent-trigger.vue | 34 +++++++++++++ .../npm-script-consent.nuxt.test.ts | 48 +++++++++++++++++++ test/unit/leaflet-registry.test.ts | 4 +- test/unit/maplibre-registry.test.ts | 4 +- test/unit/registry-readiness.test.ts | 1 + test/unit/speedcurve-primer.test.ts | 3 +- test/unit/utils.test.ts | 4 +- 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/basic/pages/consent-trigger.vue create mode 100644 test/nuxt-runtime/npm-script-consent.nuxt.test.ts diff --git a/docs/content/docs/1.guides/1.registry-scripts.md b/docs/content/docs/1.guides/1.registry-scripts.md index 9b3890cdf..641ec8194 100644 --- a/docs/content/docs/1.guides/1.registry-scripts.md +++ b/docs/content/docs/1.guides/1.registry-scripts.md @@ -157,6 +157,12 @@ useScriptGoogleAnalytics({ If several pages use the same integration, configure it once in `app.vue` or `nuxt.config`. Later composable calls return that script instance and do not need the options again. +Later calls without an explicit trigger keep the existing loading condition, including consent. +If a later call supplies a trigger, that trigger can also load the script. + +In `nuxt.config`, `trigger: false` disables automatic injection while keeping the registry configuration available. +A composable can still load that script. Set `scriptOptions.trigger: false` on the composable to require manual loading. + ::code-group ```ts [nuxt.config.ts] diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 2f8c6f14b..c0500181a 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -482,9 +482,12 @@ export default defineNuxtModule({ } Object.assign(base, defu(input, envResolved)) } - // Include scriptOptions so composable instances inherit registry-level settings (e.g. bundle) + // Inherit loading settings, but keep infrastructure-only triggers at module level. if (scriptOptions && Object.keys(scriptOptions).length > 0) { - base.scriptOptions = scriptOptions + const inheritedScriptOptions = { ...scriptOptions } + if (inheritedScriptOptions.trigger === false) + delete inheritedScriptOptions.trigger + base.scriptOptions = inheritedScriptOptions } registryWithDefaults[key] = base } diff --git a/packages/script/src/runtime/composables/useScript.ts b/packages/script/src/runtime/composables/useScript.ts index 18fd1d4c0..068a451b4 100644 --- a/packages/script/src/runtime/composables/useScript.ts +++ b/packages/script/src/runtime/composables/useScript.ts @@ -184,6 +184,7 @@ export function resolveScriptKey(input: any): string { } export function useScript = Record>(input: UseScriptInput, options?: NuxtUseScriptOptions): UseScriptContext, T>> { + const explicitTrigger = options?.trigger input = typeof input === 'string' ? { src: input } : input options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions if (!import.meta.client && options.use) { @@ -305,6 +306,9 @@ export function useScript = Record)?.[id] + // Implicit consumers observe the existing resource without adding a loading condition. + if (exists && explicitTrigger === undefined) + options.trigger = 'manual' const err = options._validate?.() if (import.meta.dev && import.meta.client && err) { diff --git a/packages/script/src/runtime/utils.ts b/packages/script/src/runtime/utils.ts index 8f82ef824..8e5ac0f32 100644 --- a/packages/script/src/runtime/utils.ts +++ b/packages/script/src/runtime/utils.ts @@ -12,10 +12,10 @@ import type { } from '#nuxt-scripts/types' import type { GcmConsentContract } from './registry/_gcm-consent' import { defu } from 'defu' -import { createError, useRuntimeConfig } from 'nuxt/app' +import { createError, useNuxtApp, useRuntimeConfig } from 'nuxt/app' import { parseQuery, parseURL, withQuery } from 'ufo' import { parse } from 'valibot' -import { useScript } from './composables/useScript' +import { resolveScriptKey, useScript } from './composables/useScript' import { createNpmScriptApiState } from './npm-script-api-state' import { createNpmScriptProxy } from './npm-script-proxy' import { attachGcmConsent } from './registry/_gcm-consent' @@ -94,6 +94,14 @@ export function useRegistryScript, O = Em const userOptions = defu(_userOptions || {}, typeof scriptConfig === 'object' ? scriptConfig : {}) const options = optionsFn(userOptions as InferIfSchema, { scriptInput: userOptions.scriptInput as UseScriptInput & { src?: string } }) + function registerScript(input: UseScriptInput, scriptOptions: NuxtUseScriptOptions) { + const key = resolveScriptKey(typeof input === 'string' ? { src: input } : input) + // Registry defaults also stay passive when a caller reuses an existing resource. + if (_userOptions?.scriptOptions?.trigger === undefined && useNuxtApp().$scripts?.[key]) + scriptOptions = { ...scriptOptions, trigger: 'manual' } + return useScript(input, scriptOptions) + } + if (options.scriptMode === 'npm') { const scriptOptions = { ...userOptions.scriptOptions, ...options.scriptOptions } as NuxtUseScriptOptions const resolveApi = scriptOptions.use @@ -103,7 +111,7 @@ export function useRegistryScript, O = Em scriptOptions.trigger = 'client' const apiState = createNpmScriptApiState(clientUse as (() => T | undefined) | undefined) - const instance = useScript({ + const instance = registerScript({ key: String(registryKey), async loader({ signal }: { signal: AbortSignal }) { const initialized = await options.clientInit?.({ signal }) @@ -222,7 +230,7 @@ export function useRegistryScript, O = Em options.clientInit?.() } } - const instance = useScript(scriptInput, scriptOptions as NuxtUseScriptOptions) + const instance = registerScript(scriptInput, scriptOptions as NuxtUseScriptOptions) if (import.meta.client && options.gcmConsent) attachGcmConsent(instance as any, options.gcmConsent, String(registryKey)) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index aa9fa2780..cfc4ed8df 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -74,6 +74,43 @@ async function waitForLogCount( } describe('basic', () => { + it.each(['generic', 'registry'])('keeps later %s consumers behind the first consent trigger', async (mode) => { + const { page, logs } = await createPage(`/consent-trigger?mode=${mode}`) + await page.waitForTimeout(1500) + expect(await page.locator('#consented').textContent()).toBe('false') + expect(await page.locator('#status').textContent()).toBe('awaitingLoad') + expect(await page.locator('#later-status').textContent()).toBe('awaitingLoad') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(0) + await page.click('#accept') + await waitForLogCount(logs, 'Script -- Loaded', 1) + await expect.poll(() => page.locator('#status').textContent()).toBe('loaded') + expect(await page.locator('#later-status').textContent()).toBe('loaded') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(1) + }) + + it('loads a composable from an infrastructure-only registry entry', async () => { + const { page, logs } = await createPage('/consent-trigger?mode=infrastructure') + await expect.poll(() => page.locator('#status').textContent(), { timeout: 5000 }).toBe('loaded') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(1) + }) + + it('preserves an explicit manual composable trigger', async () => { + const { page, logs } = await createPage('/consent-trigger?mode=manual') + await page.waitForTimeout(1500) + expect(await page.locator('#status').textContent()).toBe('awaitingLoad') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(0) + await page.click('#load') + await expect.poll(() => page.locator('#status').textContent()).toBe('loaded') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(1) + }) + + it('allows a later consumer to provide an explicit trigger', async () => { + const { page, logs } = await createPage('/consent-trigger?mode=explicit') + await expect.poll(() => page.locator('#status').textContent()).toBe('loaded') + expect(await page.locator('#consented').textContent()).toBe('false') + expect(logs().filter(log => log.text === 'Script -- Loaded')).toHaveLength(1) + }) + it('loads onNuxtReady scripts when the renderer has no idle time', { timeout: 30000 }, async () => { const { page, logs } = await createPage('/reload-trigger', undefined, true) await waitForLogCount(logs, 'Script -- Loaded', 1, 3000) diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 8c2419baa..29c56e369 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -10,6 +10,7 @@ export default defineNuxtConfig({ instagramEmbed: {}, blueskyEmbed: {}, gravatar: {}, + metaPixel: { id: '123456', trigger: false, bundle: false, scriptInput: { src: '/myScript.js' } }, }, }, devtools: { diff --git a/test/fixtures/basic/pages/consent-trigger.vue b/test/fixtures/basic/pages/consent-trigger.vue new file mode 100644 index 000000000..3a11686e8 --- /dev/null +++ b/test/fixtures/basic/pages/consent-trigger.vue @@ -0,0 +1,34 @@ + + + diff --git a/test/nuxt-runtime/npm-script-consent.nuxt.test.ts b/test/nuxt-runtime/npm-script-consent.nuxt.test.ts new file mode 100644 index 000000000..4b4484a10 --- /dev/null +++ b/test/nuxt-runtime/npm-script-consent.nuxt.test.ts @@ -0,0 +1,48 @@ +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h, onUnmounted, unref } from 'vue' +import { useScriptPostHog, useScriptTriggerConsent } from '#imports' + +const sdk = vi.hoisted(() => ({ init: vi.fn(), capture: vi.fn() })) +vi.mock('posthog-js', () => ({ default: { init: sdk.init } })) + +describe('npm script consent', () => { + it('keeps later consumers and queued events behind the first consent trigger', async () => { + sdk.init.mockReturnValue({ capture: sdk.capture }) + let accept!: () => void + const LaterConsumer = defineComponent({ + setup() { + const script = useScriptPostHog() + script.proxy.posthog.capture('after-consent') + return () => h('p', { id: 'later' }, unref(script.status)) + }, + }) + const wrapper = await mountSuspended(defineComponent({ + setup() { + const consent = useScriptTriggerConsent() + const script = useScriptPostHog({ apiKey: 'phc_test', scriptOptions: { trigger: consent, skipValidation: true } }) + accept = () => consent.accept() + onUnmounted(() => script.remove()) + return () => h('div', [h('p', { id: 'first' }, unref(script.status)), h(LaterConsumer)]) + }, + })) + try { + await new Promise(resolve => setTimeout(resolve, 100)) + expect(sdk.init).not.toHaveBeenCalled() + expect(sdk.capture).not.toHaveBeenCalled() + expect(wrapper.find('#first').text()).toBe('awaitingLoad') + expect(wrapper.find('#later').text()).toBe('awaitingLoad') + accept() + await expect.poll(() => wrapper.find('#first').text()).toBe('loaded') + expect(wrapper.find('#later').text()).toBe('loaded') + expect(sdk.init).toHaveBeenCalledTimes(1) + expect(sdk.capture).toHaveBeenCalledWith('after-consent') + } + finally { + wrapper.unmount() + delete window.posthog + delete window.__posthogInitPromise + delete window._posthogQueue + } + }) +}) diff --git a/test/unit/leaflet-registry.test.ts b/test/unit/leaflet-registry.test.ts index d319a6603..27d89cc92 100644 --- a/test/unit/leaflet-registry.test.ts +++ b/test/unit/leaflet-registry.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('nuxt/app', () => ({ + useNuxtApp: () => ({}), createError: (input: { message: string }) => new Error(input.message), useRuntimeConfig: () => ({ public: { scripts: {} } }), })) -vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ +vi.mock('../../packages/script/src/runtime/composables/useScript', async importOriginal => ({ + ...await importOriginal>(), useScript: vi.fn((input, options) => ({ input, options })), })) diff --git a/test/unit/maplibre-registry.test.ts b/test/unit/maplibre-registry.test.ts index 5275eeb9f..ef155b8e9 100644 --- a/test/unit/maplibre-registry.test.ts +++ b/test/unit/maplibre-registry.test.ts @@ -6,11 +6,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const setWorkerUrl = vi.fn() vi.mock('nuxt/app', () => ({ + useNuxtApp: () => ({}), createError: (input: { message: string }) => new Error(input.message), useRuntimeConfig: () => ({ public: { scripts: {} } }), })) -vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ +vi.mock('../../packages/script/src/runtime/composables/useScript', async importOriginal => ({ + ...await importOriginal>(), useScript: vi.fn((input, options) => ({ input, options })), })) diff --git a/test/unit/registry-readiness.test.ts b/test/unit/registry-readiness.test.ts index 6c04feee5..f7c5afc4c 100644 --- a/test/unit/registry-readiness.test.ts +++ b/test/unit/registry-readiness.test.ts @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@unhead/vue', () => ({ useHead: vi.fn() })) vi.mock('nuxt/app', () => ({ + useNuxtApp: () => ({}), createError: mocks.createError, useRuntimeConfig: mocks.useRuntimeConfig, })) diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts index 404f31455..1aa457b27 100644 --- a/test/unit/speedcurve-primer.test.ts +++ b/test/unit/speedcurve-primer.test.ts @@ -15,7 +15,8 @@ vi.mock('nuxt/app', () => ({ onNuxtReady: vi.fn(), })) -vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ +vi.mock('../../packages/script/src/runtime/composables/useScript', async importOriginal => ({ + ...await importOriginal>(), useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })), })) diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index a26763829..b6c295476 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -11,10 +11,12 @@ const useScriptMock = vi.hoisted(() => vi.fn((input, options) => ({ input, optio // Mock dependencies vi.mock('nuxt/app', () => ({ + useNuxtApp: () => ({}), useRuntimeConfig: () => runtimeConfig, })) -vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({ +vi.mock('../../packages/script/src/runtime/composables/useScript', async importOriginal => ({ + ...await importOriginal>(), useScript: useScriptMock, }))