Skip to content
Open
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
33 changes: 25 additions & 8 deletions playground/pages/features/cookie-consent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ function acceptCookies() {
scriptConsent.accept()
showCookieBanner.value = false
}
function revokeCookies() {
scriptConsent.revoke()
showCookieBanner.value = true
}
useScriptGoogleTagManager({
id: 'GTM-MWW974PF',
scriptOptions: {
Expand All @@ -18,16 +22,29 @@ useScriptGoogleTagManager({
</script>

<template>
<div v-if="showCookieBanner" id="cookie-consent" class="p-5 bg-blue-900">
<div class="font-bold mb-2">
Do you accept cookies?
<div>
<div v-if="showCookieBanner" id="cookie-consent" class="p-5 bg-blue-900">
<div class="font-bold mb-2">
Do you accept cookies?
</div>
<div class="flex items-center gap-4">
<UButton @click="acceptCookies">
Yes
</UButton>
<UButton @click="showCookieBanner = false">
No
</UButton>
</div>
</div>
<div class="flex items-center gap-4">
<UButton @click="acceptCookies">
Yes
<div v-else class="p-5 bg-gray-100">
<div class="font-bold mb-2">
Cookie Status: {{ scriptConsent.consented.value ? 'Accepted' : 'Declined' }}
</div>
<UButton v-if="scriptConsent.consented.value" color="red" @click="revokeCookies">
Revoke Consent
</UButton>
<UButton @click="showCookieBanner = false">
No
<UButton v-else color="green" @click="acceptCookies">
Accept Cookies
</UButton>
</div>
</div>
Expand Down
73 changes: 46 additions & 27 deletions src/runtime/composables/useScriptTriggerConsent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isRef, ref, toValue, watch } from 'vue'
import { isRef, type Ref, ref, toValue, watch } from 'vue'
import { tryUseNuxtApp, onNuxtReady, requestIdleCallback } from 'nuxt/app'
import type { ConsentScriptTriggerOptions } from '../types'

Expand All @@ -7,28 +7,55 @@ interface UseConsentScriptTriggerApi extends Promise<void> {
* A function that can be called to accept the consent and load the script.
*/
accept: () => void
/**
* A function that can be called to revoke consent. Since the trigger promise is already resolved
* once accepted, revocation is signaled via the reactive `consented` ref rather than promise rejection.
* Consumers should `watch(consent.consented, ...)` to react to revocation.
*/
revoke: () => void
/**
* Reactive reference to the consent state
*/
consented: Ref<boolean>
Comment on lines +15 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep the SSR return value API-compatible.

The new revoke/consented members are only attached on the client path. The import.meta.server branch at Line 28 still returns a bare Promise, so SSR callers get undefined for both and can break as soon as they read consented.value or bind revoke.

Suggested fix
 export function useScriptTriggerConsent(options?: ConsentScriptTriggerOptions): UseConsentScriptTriggerApi {
-  if (import.meta.server)
-    return new Promise(() => {}) as UseConsentScriptTriggerApi
+  if (import.meta.server) {
+    const serverPromise = new Promise<void>(() => {}) as UseConsentScriptTriggerApi
+    serverPromise.accept = () => {}
+    serverPromise.revoke = () => {}
+    serverPromise.consented = ref(false)
+    return serverPromise
+  }
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/composables/useScriptTriggerConsent.ts` around lines 15 - 19, The
SSR branch in useScriptTriggerConsent currently returns a bare Promise and thus
lacks the new members revoke and consented; update the import.meta.server path
so it returns the same API shape as the client: an object (or Promise resolving
to it) containing a noop revoke function and a reactive consented Ref<boolean>
(initialized false) so SSR callers can safely read consented.value and call
revoke without runtime errors; locate the return in the import.meta.server
branch and add these members to match the client return shape.

}

/**
* Load a script once consent has been provided either through a resolvable `consent` or calling the `accept` method.
* Supports revoking consent via the reactive `consented` ref. Consumers should watch `consented` to react to revocation.
* @param options
*/
export function useScriptTriggerConsent(options?: ConsentScriptTriggerOptions): UseConsentScriptTriggerApi {
if (import.meta.server)
return new Promise(() => {}) as UseConsentScriptTriggerApi

const consented = ref<boolean>(false)
// user may want ot still load the script on idle
const nuxtApp = tryUseNuxtApp()

// Setup initial consent value
if (options?.consent) {
if (isRef(options?.consent)) {
watch(options.consent, (_val) => {
const val = toValue(_val)
consented.value = Boolean(val)
}, { immediate: true })
}
// check for boolean primitive
else if (typeof options?.consent === 'boolean') {
consented.value = options?.consent
}
// consent is a promise
else if (options?.consent instanceof Promise) {
options?.consent.then((res) => {
consented.value = typeof res === 'boolean' ? res : true
})
}
}
Comment on lines +34 to +52
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

🧩 Analysis chain

🏁 Script executed:

cat -n src/runtime/composables/useScriptTriggerConsent.ts | head -80

Repository: nuxt/scripts

Length of output: 3546


🌐 Web query:

Vue 3 watch callback immediate false does not trigger if value changed before registration

πŸ’‘ Result:

In Vue 3 this is expected:

  • watch() is lazy by default: with immediate: false (the default), the callback only runs for changes that happen after the watcher is created. If the value already changed before you register the watcher, there’s no β€œmissed event queue” to replay, so nothing fires. Vue’s docs: β€œthe callback won't be called until the watched source has changed.” [1]
  • Also, watchers should be created synchronously inside setup(); if you create them later (e.g., after an await, in a timeout, or in some async init), you can easily miss earlier changes. Vue explicitly warns that async-created watchers are a special case and synchronous creation is preferred. [1]

Fix options

  1. If you must react to the current value too, use immediate: true:
watch(source, (nv, ov) => {
  // runs once immediately, then on future changes
}, { immediate: true })

[1]

  1. Register the watcher earlier (synchronously), and gate your logic instead of delaying registration:
const ready = ref(false)

watch(source, (nv) => {
  if (!ready.value) return
  // ...
})

onMounted(() => { ready.value = true })

(avoids missing changes while still preventing early side effects)

  1. Use watchEffect() if β€œrun once now + rerun on dependency changes” is what you actually want:
    watchEffect() runs immediately and tracks dependencies automatically. [1]

Sources:
[1] Vue docs β€” Watchers (lazy by default, immediate, watchEffect, and note about creating watchers synchronously in setup()). (vuejs.org)

Citations:


Consent is lost if granted before the watcher registers.

Lines 38, 42, and 47 can set consented.value = true synchronously. However, the watch(consented, ...) at line 53 is registered without immediate: true, meaning it only fires for changes that occur after the watcher is created. If consent is granted before line 53 executes, the watcher never sees the false β†’ true transition and the promise stays pending indefinitely.

This occurs when:

  • options.consent is a boolean true
  • options.consent is a Ref with an initial value of true
  • options.consent is a Promise that resolves quickly
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/composables/useScriptTriggerConsent.ts` around lines 32 - 50, The
watcher on consented (in useScriptTriggerConsent.ts) is registered without
immediate: true, so if consented.value is set to true earlier (via the boolean
branch, the Ref branch, or a quickly-resolving Promise) the watcher never fires;
update the watch(consented, ...) call to include { immediate: true } so the
callback runs immediately with the current value (ensuring the promise/flow that
waits on the consent transition resolves even when consent was already granted).


const promise = new Promise<void>((resolve) => {
watch(consented, (ready) => {
if (ready) {
watch(consented, (newValue, oldValue) => {
if (newValue && !oldValue) {
// Consent granted - load script
const runner = nuxtApp?.runWithContext || ((cb: () => void) => cb())
// TODO drop support in v1
if (options?.postConsentTrigger instanceof Promise) {
options.postConsentTrigger.then(() => runner(resolve))
return
}
if (typeof options?.postConsentTrigger === 'function') {
// check if function has an argument
if (options?.postConsentTrigger.length === 1) {
Expand All @@ -50,29 +77,21 @@ export function useScriptTriggerConsent(options?: ConsentScriptTriggerOptions):
// other trigger not supported
runner(resolve)
}
// Revocation is handled via the reactive `consented` ref, not promise rejection.
// Once resolved, a promise cannot be rejected β€” consumers should watch `consented` instead.
})
if (options?.consent) {
if (isRef(options?.consent)) {
watch(options.consent, (_val) => {
const val = toValue(_val)
consented.value = Boolean(val)
}, { immediate: true })
}
// check for boolean primitive
else if (typeof options?.consent === 'boolean') {
consented.value = options?.consent
}
// consent is a promise
else if (options?.consent instanceof Promise) {
options?.consent.then((res) => {
consented.value = typeof res === 'boolean' ? res : true
})
}
}
}) as UseConsentScriptTriggerApi

// we augment the promise with a consent API
promise.accept = () => {
consented.value = true
}

promise.revoke = () => {
consented.value = false
}

promise.consented = consented

return promise as UseConsentScriptTriggerApi
}