`useCookie` with `refresh: true` rewrites the cookie in an endless loop since 4.6.0 · Issue #36656 · nuxt/nuxt · GitHub
Skip to content

useCookie with refresh: true rewrites the cookie in an endless loop since 4.6.0 #36656

Closed

Description

@ilyaDegtyarenko

Environment

Operating system macOS 27.0.0
Node.js version v26.12.0
nuxt/cli version 4.0.1
Nuxt version 4.6.1 (4.5.2 is not affected)
Vue version 3.5.43
Nitro version 2.13.5
Builder vite@8.3.4
Config / Modules —
Browser Chrome 155 (Cookie Store API available)

Reproduction

https://gist.github.com/ilyaDegtyarenko/050172b2e1c0e7586264022411874edd

Minimal app.vue, no config, no modules:

<script setup lang="ts">
const counter = useCookie<number>('counter', { maxAge: 3600, refresh: true })
const changeEvents = ref(0)

onMounted(() => {
  globalThis.cookieStore?.addEventListener('change', (event) => {
    if (event.changed.some(c => c.name === 'counter')) {
      changeEvents.value++
    }
  })
})
</script>

<template>
  <button @click="counter = (counter ?? 0) + 1">counter++</button>
  <p>cookieStore change events: {{ changeEvents }}</p>
</template>
  1. Open the page in Chrome. The counter stays at 0 while idle.
  2. Click the button once, or run cookieStore.set('counter', '1') in the console.
  3. The counter climbs by about 12–21k per second and never stops until the page is reloaded.

Change events within 3 s of a single trigger:

Nuxt options trigger events
4.6.1 refresh: true, maxAge external cookieStore.set 48,180
4.6.1 refresh: true, maxAge ref assignment 37,564 – 64,463
4.6.1 maxAge only external cookieStore.set 1
4.6.1 refresh: true, no maxAge external cookieStore.set 2
4.5.2 refresh: true, maxAge either 1

Describe the bug

Since #36006 ("use native vue pausable watcher for useCookie", first released in 4.6.0), syncing a cookie change back into the ref writes the cookie again. With refresh: true this turns into an endless write → change → write loop:

  1. A cookieStore change event calls handleChange, which runs cookieWatcher.pause(), assigns cookie.value, and calls nextTick(() => cookieWatcher.resume()).
  2. With maxAge/expires, the cookie is a customRef whose setter always calls trigger(), even for an equal value.
  3. Vue 3.5 does not drop a trigger that arrives while the effect is paused: it adds the effect to pausedQueueEffects and replays it on resume(). The watcher is deep by default, so the callback runs even though the value is unchanged.
  4. The callback is callback(opts.refresh). With refresh: true it skips the isEqual check and writes document.cookie.
  5. The write fires a new change event, which goes back to step 1.

Before #36006, handleChange set a watchPaused flag that made the watcher return early. The trigger was dropped rather than deferred, so 4.5.2 does not loop. With refresh: false, the replayed callback is stopped by isEqual, because handleChange already updated the snapshot.

Expected behaviour: a value received from a change event (or from the BroadcastChannel) is applied to the ref without writing the cookie back. refresh: true should only affect assignments made by the app.

Additional context

  • Defaults are enough to hit it: experimental.cookieStore is true and watch is true. You only need refresh: true together with maxAge or expires.
  • Impact in our production app: about 17k cookie writes per second on the main thread for as long as the component that owns the cookie stays mounted. A write from any same-origin tab starts the loop in every tab that has the cookie mounted.
  • Not verified, from reading the code: object or array values may loop even without maxAge, because decode returns a new object on every event. The BroadcastChannel fallback (no Cookie Store API) may ping-pong between two tabs in the same way.
  • A possible fix is to restore the pre-refactor(nuxt): use native vue pausable watcher for useCookie #36006 semantics in handleChange, so the watcher run caused by the sync is dropped instead of replayed on resume().

Logs

No response

Activity

  1. added theissue type on Oct 21, 2026
  2. FelixFangX commented on Oct 22, 2026

    @FelixFangX
    Contributor

    This looks like it came in with #36006 (switch to Vue's pausable watcher).

    handleChange pauses the watcher, assigns cookie.value, and resumes it on nextTick (cookie.ts#L178-L186). With the native pause()/resume(), though, a trigger that fires while paused isn't dropped. It runs once on resume(), even when the value didn't change. Quick check on vue 3.5.44:

    let v = 1
    const r = customRef((track, trigger) => ({ get() { track(); return v }, set(n) { v = n; trigger() } }))
    let calls = 0
    const w = watch(r, () => calls++, { deep: true })
    w.pause(); r.value = 1; await nextTick(); w.resume(); await nextTick()
    console.log(calls) // 1

    Normally that extra call is harmless because callback bails out on isEqual(cookie.value, cookies[name]). The watcher calls callback(opts.refresh) though (L221), and force skips that check (L163-L166). So with refresh: true it writes the cookie again. With maxAge, each write sets a new expiry, so the CookieStore fires another change, which goes through handleChange again. That matches the endless rewrite in your gist.

    The fix should be small: a value that arrives through handleChange shouldn't be written back, even with refresh set. I have a patch along those lines plus a regression test (an update via handleChange shouldn't call writeClientCookie). I can open a PR if that works for you.

  3. rynx-cc commented on Oct 23, 2026

    @rynx-cc

    looks like the loop comes from handleChange → assigning cookie.value → customRef setter always calls trigger() even when the value hasn't actually changed → fires the watcher again.

    in 4.5.2 the old watcher setup didn't have this problem, so #36006 must have changed the pause/resume timing enough that the re-trigger slips through.

    a quick fix might be to bail out of the setter when the new value equals the current one (before calling trigger() ), or to compare inside handleChange before the assignment.

    happy to take a stab at a PR if the approach sounds right — anyone already working on this?

  4. github-actions commented on Oct 27, 2026

    @github-actions
    Contributor

    This issue was resolved by #36673, which was released in v4.6.3.

    If you are still seeing this problem after upgrading, please let us know (ideally with an updated reproduction) and we will take another look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions