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
6 changes: 6 additions & 0 deletions docs/content/docs/1.guides/1.registry-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,12 @@ export default defineNuxtModule<ModuleOptions>({
}
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
}
Expand Down
4 changes: 4 additions & 0 deletions packages/script/src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function resolveScriptKey(input: any): string {
}

export function useScript<T extends Record<symbol | string, any> = Record<symbol | string, any>>(input: UseScriptInput, options?: NuxtUseScriptOptions<T>): UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> {
const explicitTrigger = options?.trigger
input = typeof input === 'string' ? { src: input } : input
options = defu(options, useNuxtScriptRuntimeConfig()?.defaultScriptOptions) as NuxtUseScriptOptions<T>
if (!import.meta.client && options.use) {
Expand Down Expand Up @@ -305,6 +306,9 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
const headHooks = options.head.hooks!
ensureScripts(nuxtApp)
const exists = !!(nuxtApp.$scripts as Record<string, any>)?.[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) {
Expand Down
16 changes: 12 additions & 4 deletions packages/script/src/runtime/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -94,6 +94,14 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
const userOptions = defu(_userOptions || {}, typeof scriptConfig === 'object' ? scriptConfig : {})
const options = optionsFn(userOptions as InferIfSchema<O>, { scriptInput: userOptions.scriptInput as UseScriptInput & { src?: string } })

function registerScript(input: UseScriptInput, scriptOptions: NuxtUseScriptOptions<T>) {
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<T>(input, scriptOptions)
}

if (options.scriptMode === 'npm') {
const scriptOptions = { ...userOptions.scriptOptions, ...options.scriptOptions } as NuxtUseScriptOptions<T>
const resolveApi = scriptOptions.use
Expand All @@ -103,7 +111,7 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
scriptOptions.trigger = 'client'

const apiState = createNpmScriptApiState(clientUse as (() => T | undefined) | undefined)
const instance = useScript<T>({
const instance = registerScript({
key: String(registryKey),
async loader({ signal }: { signal: AbortSignal }) {
const initialized = await options.clientInit?.({ signal })
Expand Down Expand Up @@ -222,7 +230,7 @@ export function useRegistryScript<T extends Record<string | symbol, any>, O = Em
options.clientInit?.()
}
}
const instance = useScript<T>(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)
const instance = registerScript(scriptInput, scriptOptions as NuxtUseScriptOptions<T>)

if (import.meta.client && options.gcmConsent)
attachGcmConsent(instance as any, options.gcmConsent, String(registryKey))
Expand Down
37 changes: 37 additions & 0 deletions test/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/basic/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineNuxtConfig({
instagramEmbed: {},
blueskyEmbed: {},
gravatar: {},
metaPixel: { id: '123456', trigger: false, bundle: false, scriptInput: { src: '/myScript.js' } },
},
},
devtools: {
Expand Down
34 changes: 34 additions & 0 deletions test/fixtures/basic/pages/consent-trigger.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { useRoute, useScript, useScriptMetaPixel, useScriptTriggerConsent } from '#imports'
import { computed, defineComponent, h, unref } from 'vue'

const mode = useRoute().query.mode
const consent = useScriptTriggerConsent()
const first = mode === 'infrastructure'
? useScriptMetaPixel()
: mode === 'manual'
? useScriptMetaPixel({ scriptOptions: { trigger: false } })
: mode === 'registry'
? useScriptMetaPixel({ scriptOptions: { trigger: consent } })
: useScript('/myScript.js', { trigger: consent, bundle: false })
const status = computed(() => first.status)

const LaterConsumer = defineComponent({
setup() {
const later = mode === 'registry'
? useScriptMetaPixel()
: mode === 'explicit'
? useScript('/myScript.js', { trigger: 'client', bundle: false })
: useScript('/myScript.js')
return () => h('p', { id: 'later-status' }, unref(later.status))
},
})
</script>

<template>
<p id="status">{{ status }}</p>
<p id="consented">{{ consent.consented }}</p>
<LaterConsumer v-if="mode === 'registry' || mode === 'generic' || mode === 'explicit'" />
<button id="accept" @click="consent.accept()">Accept</button>
<button id="load" @click="first.load()">Load</button>
</template>
48 changes: 48 additions & 0 deletions test/nuxt-runtime/npm-script-consent.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
})
4 changes: 3 additions & 1 deletion test/unit/leaflet-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(),
useScript: vi.fn((input, options) => ({ input, options })),
}))

Expand Down
4 changes: 3 additions & 1 deletion test/unit/maplibre-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>(),
useScript: vi.fn((input, options) => ({ input, options })),
}))

Expand Down
1 change: 1 addition & 0 deletions test/unit/registry-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down
3 changes: 2 additions & 1 deletion test/unit/speedcurve-primer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>(),
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))

Expand Down
4 changes: 3 additions & 1 deletion test/unit/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>(),
useScript: useScriptMock,
}))

Expand Down
Loading