From 034d1aaa6ffb4bd6d7b89563959cc801fc22a0e0 Mon Sep 17 00:00:00 2001 From: Enkot Date: Mon, 29 Jan 2024 12:37:32 +0200 Subject: [PATCH] feat(nuxt): `refreshCookie` + experimental CookieStore support (#25198) --- .../1.experimental-features.md | 16 +++++++ docs/3.api/3.utils/refresh-cookie.md | 46 +++++++++++++++++++ packages/nuxt/index.d.ts | 6 +++ packages/nuxt/src/app/composables/cookie.ts | 31 ++++++++++--- packages/nuxt/src/core/templates.ts | 1 + packages/nuxt/src/imports/presets.ts | 2 +- packages/schema/src/config/experimental.ts | 6 +++ test/basic.test.ts | 8 +++- test/fixtures/basic/pages/cookies.vue | 7 ++- 9 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 docs/3.api/3.utils/refresh-cookie.md diff --git a/docs/2.guide/3.going-further/1.experimental-features.md b/docs/2.guide/3.going-further/1.experimental-features.md index de1717faaa..1410fb0b84 100644 --- a/docs/2.guide/3.going-further/1.experimental-features.md +++ b/docs/2.guide/3.going-further/1.experimental-features.md @@ -374,3 +374,19 @@ import { Buffer } from 'node:buffer' globalThis.Buffer = globalThis.Buffer || Buffer ``` :: + +## cookieStore + +Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. + +```ts [nuxt.config.ts] +export defineNuxtConfig({ + experimental: { + cookieStore: true + } +}) +``` + +::read-more{icon="i-simple-icons-mdnwebdocs" color="gray" to="https://developer.mozilla.org/en-US/docs/Web/API/CookieStore" target="_blank"} +Read more about the **CookieStore**. +:: diff --git a/docs/3.api/3.utils/refresh-cookie.md b/docs/3.api/3.utils/refresh-cookie.md new file mode 100644 index 0000000000..cd73935647 --- /dev/null +++ b/docs/3.api/3.utils/refresh-cookie.md @@ -0,0 +1,46 @@ +--- +title: "refreshCookie" +description: "Refresh useCookie values manually when a cookie has changed" +navigation: + badge: New +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/cookie.ts + size: xs +--- + +::callout{icon="i-ph-info-duotone" color="blue"} +This utility is available since [Nuxt v3.10](/blog/v3-10). +:: + +## Purpose + +The `refreshCookie` function is designed to refresh cookie value returned by `useCookie`. + +This is useful for updating the `useCookie` ref when we know the new cookie value has been set in the browser. + +## Usage + +```vue [app.vue] + +``` + +::callout{to="/docs/guide/going-further/experimental-features#cookiestore"} +You can enable experimental `cookieStore` option to automatically refresh `useCookie` value when cookie changes in the browser. +:: + +## Type + +```ts +refreshCookie(name: string): void +``` diff --git a/packages/nuxt/index.d.ts b/packages/nuxt/index.d.ts index 3135b899a1..2256348248 100644 --- a/packages/nuxt/index.d.ts +++ b/packages/nuxt/index.d.ts @@ -11,6 +11,12 @@ declare global { effectiveType: 'slow-2g' | '2g' | '3g' | '4g' } } + + interface Window { + cookieStore?: { + onchange: (event: any) => void + } + } } export {} diff --git a/packages/nuxt/src/app/composables/cookie.ts b/packages/nuxt/src/app/composables/cookie.ts index 5c29fa9084..1e6ee18ea9 100644 --- a/packages/nuxt/src/app/composables/cookie.ts +++ b/packages/nuxt/src/app/composables/cookie.ts @@ -10,6 +10,9 @@ import { klona } from 'klona' import { useNuxtApp } from '../nuxt' import { useRequestEvent } from './ssr' +// @ts-expect-error virtual import +import { cookieStore } from '#build/nuxt.config.mjs' + type _CookieOptions = Omit export interface CookieOptions extends _CookieOptions { @@ -29,6 +32,8 @@ const CookieDefaults = { encode: val => encodeURIComponent(typeof val === 'string' ? val : JSON.stringify(val)) } satisfies CookieOptions +const store = import.meta.client && cookieStore ? window.cookieStore : undefined + /** @since 3.0.0 */ export function useCookie (name: string, _opts?: CookieOptions & { readonly?: false }): CookieRef export function useCookie (name: string, _opts: CookieOptions & { readonly: true }): Readonly> @@ -58,7 +63,7 @@ export function useCookie (name: string, _opts?: } if (import.meta.client) { - const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`) + const channel = store || typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(`nuxt:cookies:${name}`) const callback = () => { if (opts.readonly || isEqual(cookie.value, cookies[name])) { return } writeClientCookie(name, cookie.value, opts as CookieSerializeOptions) @@ -67,6 +72,13 @@ export function useCookie (name: string, _opts?: channel?.postMessage(opts.encode(cookie.value as T)) } + const handleChange = (data: { value?: any, refresh?: boolean }) => { + const value = data.refresh ? readRawCookies(opts)?.[name] : opts.decode(data.value) + watchPaused = true + cookies[name] = cookie.value = value + nextTick(() => { watchPaused = false }) + } + let watchPaused = false if (getCurrentScope()) { @@ -77,12 +89,13 @@ export function useCookie (name: string, _opts?: }) } - if (channel) { - channel.onmessage = (event) => { - watchPaused = true - cookies[name] = cookie.value = opts.decode(event.data) - nextTick(() => { watchPaused = false }) + if (store) { + store.onchange = (event) => { + const cookie = event.changed.find((c: any) => c.name === name) + if (cookie) handleChange({ value: cookie.value }) } + } else if (channel) { + channel.onmessage = ({ data }) => handleChange(data) } if (opts.watch) { @@ -109,6 +122,12 @@ export function useCookie (name: string, _opts?: return cookie as CookieRef } +/** @since 3.10.0 */ +export function refreshCookie(name: string) { + if (store || typeof BroadcastChannel === 'undefined') return + + new BroadcastChannel(`nuxt:cookies:${name}`)?.postMessage({ refresh: true }) +} function readRawCookies (opts: CookieOptions = {}): Record | undefined { if (import.meta.server) { diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index a28c320eeb..55ee480e13 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -378,6 +378,7 @@ export const nuxtConfigTemplate: NuxtTemplate = { `export const renderJsonPayloads = ${!!ctx.nuxt.options.experimental.renderJsonPayloads}`, `export const componentIslands = ${!!ctx.nuxt.options.experimental.componentIslands}`, `export const payloadExtraction = ${!!ctx.nuxt.options.experimental.payloadExtraction}`, + `export const cookieStore = ${!!ctx.nuxt.options.experimental.cookieStore}`, `export const appManifest = ${!!ctx.nuxt.options.experimental.appManifest}`, `export const remoteComponentIslands = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.remoteIsland}`, `export const selectiveClient = ${typeof ctx.nuxt.options.experimental.componentIslands === 'object' && ctx.nuxt.options.experimental.componentIslands.selectiveClient}`, diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 8259fa6020..ae1b8f474e 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -62,7 +62,7 @@ const granularAppPresets: InlinePreset[] = [ from: '#app/composables/fetch' }, { - imports: ['useCookie'], + imports: ['useCookie', 'refreshCookie'], from: '#app/composables/cookie' }, { diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 2ec9cd0a1e..46b0c1397a 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -287,6 +287,12 @@ export default defineUntypedSchema({ * }) */ sharedPrerenderData: false, + + /** + * Enables CookieStore support to listen for cookie updates (if supported by the browser) and refresh `useCookie` ref values. + * @see [CookieStore](https://developer.mozilla.org/en-US/docs/Web/API/CookieStore) + */ + cookieStore: false, /** * This allows specifying the default options for core Nuxt components and composables. diff --git a/test/basic.test.ts b/test/basic.test.ts index fc6dd4d9f5..14e1f5ec29 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -488,10 +488,14 @@ describe('nuxt composables', () => { return JSON.parse(decodeURIComponent(raw)) } expect(await extractCookie()).toEqual({ foo: 'bar' }) - await page.getByRole('button').click() + await page.getByText('Change cookie').click() expect(await extractCookie()).toEqual({ foo: 'baz' }) - await page.getByRole('button').click() + await page.getByText('Change cookie').click() expect(await extractCookie()).toEqual({ foo: 'bar' }) + await page.evaluate(() => document.cookie = 'updated=foobar') + await page.getByText('Refresh cookie').click() + const text = await page.innerText('pre') + expect(text).toContain('foobar') await page.close() }) }) diff --git a/test/fixtures/basic/pages/cookies.vue b/test/fixtures/basic/pages/cookies.vue index 1875097b11..b4d2dfb67a 100644 --- a/test/fixtures/basic/pages/cookies.vue +++ b/test/fixtures/basic/pages/cookies.vue @@ -4,6 +4,8 @@ useCookie('accessed-with-default-value', () => 'default') useCookie('set').value = 'set' useCookie('set-to-null').value = null useCookie('set-to-null-with-default', () => 'default').value = null + +const updated = useCookie('updated') // the next set are all sent by browser useCookie('browser-accessed-but-not-used') useCookie('browser-accessed-with-default-value', () => 'default') @@ -19,9 +21,12 @@ const objectCookie = useCookie('browser-object-default', {