You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
useCookie with refresh: true rewrites the cookie in an endless loop since 4.6.0 #36656
Open the page in Chrome. The counter stays at 0 while idle.
Click the button once, or run cookieStore.set('counter', '1') in the console.
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:
A cookieStorechange event calls handleChange, which runs cookieWatcher.pause(), assigns cookie.value, and calls nextTick(() => cookieWatcher.resume()).
With maxAge/expires, the cookie is a customRef whose setter always calls trigger(), even for an equal value.
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.
The callback is callback(opts.refresh). With refresh: true it skips the isEqual check and writes document.cookie.
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.
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:
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.
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?
Activity
FelixFangX commentedon Oct 22, 2026
This looks like it came in with #36006 (switch to Vue's pausable watcher).
handleChangepauses the watcher, assignscookie.value, and resumes it on nextTick (cookie.ts#L178-L186). With the nativepause()/resume(), though, a trigger that fires while paused isn't dropped. It runs once onresume(), even when the value didn't change. Quick check on vue 3.5.44:Normally that extra call is harmless because
callbackbails out onisEqual(cookie.value, cookies[name]). The watcher callscallback(opts.refresh)though (L221), andforceskips that check (L163-L166). So withrefresh: trueit writes the cookie again. WithmaxAge, each write sets a new expiry, so the CookieStore fires anotherchange, which goes throughhandleChangeagain. That matches the endless rewrite in your gist.The fix should be small: a value that arrives through
handleChangeshouldn't be written back, even withrefreshset. I have a patch along those lines plus a regression test (an update viahandleChangeshouldn't callwriteClientCookie). I can open a PR if that works for you.rynx-cc commentedon Oct 23, 2026
looks like the loop comes from
handleChange→ assigningcookie.value→customRefsetter always callstrigger()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 insidehandleChangebefore the assignment.happy to take a stab at a PR if the approach sounds right — anyone already working on this?
github-actions commentedon Oct 27, 2026
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.